developers

Fastify and TypeScript Tutorial: Build a CRUD API

Learn how to use TypeScript to build a feature-complete Fastify API. Tutorial on how to use TypeScript with Fastify to create, read, update, and delete data.

Apr 17, 202524 min read

Implement findAll

Let's add the method to retrieve all documents for a given user context. Inside the createDocumentService function, before the return statement, add the findAll function:

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  const { logger, prisma } = params;
  // ... errorPrefix, infoPrefix ...

  /**
   * Retrieves all documents from the database owned by a specific user.
   * @param ownerId - The ID of the user context (provided by the caller, e.g., router).
   * @returns Result containing an array of documents or a ServiceErrorModel.
   */
  const findAll = async (
ownerId: string // Service expects the owner context ID
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
try {
  const documents: DocumentWithOwner[] = await prisma.document.findMany({
where: { ownerId }, // Use the provided ownerId for filtering
include: {
  owner: true,
},
  });

  return ok(documents);
} catch (error) {
  logger.error(
{ err: error, ownerId },
`${errorPrefix} Failed to fetch documents for owner`
  );

  return err({
code: ServiceErrorCode.InternalError,
message: "Failed to find documents",
  });
}
  };

  // Expose public methods
  return { findAll };
};

The findAll method retrieves all documents along with their related owner information by calling the findMany() method on the prisma.document model to fetch all records from the Document table. The include: { owner: true } parameter tells Prisma to also fetch the related owner record for each document through their relationship. The result is stored in the documents variable, an array of type DocumentWithOwner[] containing all document objects with their properties plus the complete owner object nested inside each document.

The method wraps the result (or caught error) in neverthrow's ok or err helpers. The return type Promise<Result<DocumentWithOwner[], ServiceErrorModel>> indicates it returns either an array of the DocumentWithOwner type on success or a ServiceErrorModel on failure.

You update the return object from the createDocumentService function to include findAll.

Implement find by ID

Next, add the method to find a single document by its unique ID, ensuring it belongs to the user context provided. Add the find function inside createDocumentService before the return. This handles the expected "not found" case as a predictable error.

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  // ... logger, prisma, prefixes ...

  const findAll = async (
ownerId: string
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
/* ... */
  };

  /**
   * Retrieves a document by ID, ensuring it belongs to the specified user context.
   * @param id - The ID of the document to fetch.
   * @param ownerId - The ID of the user context (provided by the caller).
   * @returns Result containing the document or a ServiceErrorModel.
   */
  const find = async (
id: string,
ownerId: string // Service expects the owner context ID
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
try {
  const document: DocumentWithOwner | null =
await prisma.document.findUnique({
  where: { id, ownerId }, // Use both id and the provided ownerId
  include: {
owner: true,
  },
});

  if (!document) {
logger.warn({ id, ownerId }, `${errorPrefix} Document not found`);

return err({
  code: ServiceErrorCode.NotFound,
  message: `Document with ID ${id} not found for this owner`,
});
  }

  return ok(document);
} catch (error) {
  logger.error(
{ err: error, id, ownerId },
`${errorPrefix} Failed to fetch document for owner`
  );

  return err({
code: ServiceErrorCode.InternalError,
message: `Failed to find a document with ID ${id}`,
  });
}
  };

  // Expose public methods
  return { findAll, find }; // Update the return object
};

The find method uses Prisma's findUnique() method, searching for a document that matches both the provided id and the ownerId from the user context.

If findUnique returns null, you treat that as a predictable NotFound error and return an err result with the appropriate ServiceErrorCode.

You also update the return object from the createDocumentService function to include find.

Implement create with ID Generation

You need a method that takes the validated input data, generates the internal UUID, and saves the new document using the ownerId provided by the caller (the router).

Add the create method inside createDocumentService after the find method definition:

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  // ... logger, prisma, prefixes ...

  const findAll = async (
ownerId: string
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
/* ... */
  };

  const find = async (
id: string,
ownerId: string
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
/* ... */
  };

  /**
   * Creates a document with a generated UUIDv7 for a specific user.
   * @param data - The document data (title, content). Expected to include ownerId provided by the caller.
   * @returns Result containing the created document or a ServiceErrorModel.
   */
  const create = async (
// Service expects data object containing title, content, and ownerId
data: CreateDocumentInput & { ownerId: string }
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
const id = uuidv7();

try {
  const newDocument: DocumentWithOwner = await prisma.document.create({
// Use title, content, and ownerId from the data object
data: {
  id,
  title: data.title,
  content: data.content,
  ownerId: data.ownerId,
},
include: {
  owner: true,
},
  });

  logger.info(
{
  documentId: newDocument.id,
  title: newDocument.title,
  ownerId: newDocument.ownerId, // Log the ownerId used
},
`${infoPrefix} Document created successfully`
  );

  return ok(newDocument);
} catch (error) {
  // Catch block for Prisma P2002 (Unique constraint violation)
  if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
  ) {
logger.warn(
  { title: data.title, ownerId: data.ownerId, error: error.meta },
  `${errorPrefix} Failed to create document due to unique constraint violation.`
);
return err({
  code: ServiceErrorCode.Conflict,
  message: `Document creation failed: A document with similar properties might already exist for the user.`,
});
  }
  logger.error(
{ err: error, title: data.title, ownerId: data.ownerId },
`${errorPrefix} Failed to create document`
  );
  return err({
code: ServiceErrorCode.InternalError,
message: "Failed to create document",
  });
}
  };

  // Expose public methods
  return { findAll, find, create };
};

This create method generates the unique id using uuidv7(). It expects the caller (the router) to provide an input object containing title, content, and the ownerId representing the user context. It uses these values in prisma.document.create(). It specifically catches Prisma's P2002 error code for unique constraint violations, returning a Conflict error.

As before, you add the return object from the createDocumentService function to include create.

Implement update by ID

You need a method that updates a document found by its ID, accepting partial data, and the ownerId passed by the caller (router) to ensure the update is only performed if the document belongs to that user context. It also catches the "record not found" error (P2025) from Prisma.

Add the update function inside createDocumentService after the create method.

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  // ... logger, prisma, prefixes ...

  const findAll = async (
ownerId: string
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
/* ... */
  };
  const find = async (
id: string,
ownerId: string
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
/* ... */
  };
  const create = async (
data: CreateDocumentInput
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
/* ... */
  };

  /**
   * Updates an existing document by ID, ensuring the user context owns the document.
   * @param id - The ID of the document to update.
   * @param data - Update data (optional title/content), including the ownerId from the user context.
   * @returns Result containing the updated document or a ServiceErrorModel.
   */
  const update = async (
id: string,
data: UpdateDocumentInput & { ownerId: string }
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
const { ownerId, ...updateData } = data;

try {
  const updatedDocument: DocumentWithOwner = await prisma.document.update({
where: { id, ownerId },
data: updateData,
include: {
  owner: true,
},
  });

  logger.info(
{
  documentId: updatedDocument.id,
  title: updatedDocument.title,
  ownerId: updatedDocument.ownerId,
},
`${infoPrefix} Document updated successfully`
  );

  return ok(updatedDocument);
} catch (error) {
  if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2025" // Record to update not found (or ownerId didn't match)
  ) {
logger.warn(
  { id, ownerId },
  `${errorPrefix} Document not found for update`
);
return err({
  code: ServiceErrorCode.NotFound,
  message: `Document with ID ${id} not found for this owner`,
});
  }
  // Catch block for Prisma P2002 (Unique constraint violation) during update
  if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
  ) {
logger.warn(
  { id, data: updateData, ownerId: ownerId, error: error.meta },
  `${errorPrefix} Failed to update document due to unique constraint violation.`
);
return err({
  code: ServiceErrorCode.Conflict,
  message: `Document update failed: The changes conflict with another existing document.`,
});
  }

  logger.error(
{ err: error, id, ownerId },
`${errorPrefix} Failed to update document`
  );

  return err({
code: ServiceErrorCode.InternalError,
message: `Failed to update document with ID ${id}`,
  });
}
  };

  // Expose public methods
  return { findAll, find, create, update };
};

The update method uses prisma.document.update(). The where clause uses both the document id and the ownerId provided by the caller (the router). This ensures that the update only proceeds if a document with that id exists and it belongs to the specified ownerId. It catches the P2025 error code, mapping it to the NotFound service error.

The return object from the createDocumentService function includes updates as well.

Implement remove by ID

Finally, let's implement the deletion logic. It requires both the document id and the ownerId from the caller (router) to ensure the correct document is deleted. It handles the P2025 "not found" error.

Add the remove function inside createDocumentService after the update method definition:

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  // ... logger, prisma, prefixes ...

  const findAll = async (
ownerId: string
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
/* ... */
  };
  const find = async (
id: string,
ownerId: string
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
/* ... */
  };
  const create = async (
data: CreateDocumentInput & { ownerId: string }
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
/* ... */
  };
  const update = async (
id: string,
data: UpdateDocumentInput & { ownerId: string }
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
/* ... */
  };

  /**
   * Deletes a document by ID, ensuring the user context owns the document.
   * @param id - The ID of the document to delete.
   * @param ownerId - The ID of the user context (provided by the caller).
   * @returns Result containing void or a ServiceErrorModel.
   */
  const remove = async (
id: string,
ownerId: string
  ): Promise<Result<void, ServiceErrorModel>> => {
try {
  await prisma.document.delete({
where: { id, ownerId },
  });

  logger.info(
{ documentId: id, ownerId },
`${infoPrefix} Document deleted successfully by owner`
  );

  return ok(void 0);
} catch (error) {
  if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2025" // Record to delete not found (or ownerId didn't match)
  ) {
logger.warn(
  { id, ownerId },
  `${errorPrefix} Document not found for deletion`
);
return err({
  code: ServiceErrorCode.NotFound,
  message: `Document with ID ${id} not found for this owner`,
});
  }

  logger.error(
{ err: error, id, ownerId },
`${errorPrefix} Failed to delete document`
  );
  return err({
code: ServiceErrorCode.InternalError,
message: `Failed to delete document with ID ${id}`,
  });
}
  };

  // Expose public methods
  return { findAll, find, create, update, remove };
};

The remove method uses prisma.document.delete(). Similar to update, the where clause uses both id and the ownerId provided by the caller (router) to ensure the correct document is targeted. It returns ok(void 0) on success and handles the P2025 error if the document doesn't exist or isn't owned by the specified user context.

The returned object from the createDocumentService function now includes remove to have all the methods related to CRUD operations on the document resource.

With these steps, the service layer (document.service.ts) is complete. Its methods accept the necessary ownerId to perform context-aware operations, ready to be called by the router, which will supply this context (initially simulated, later from authentication).

⚠️ If you have had any issues until now, you can check the git repository's commit Commit 8.

Create a Custom Plugin For Services

Earlier, we created a prismaPlugin that handles the database connection nicely. But where should we create our DocumentService? We could technically do it right in src/app.ts, but that file's already coordinating quite a bit. Plus, as our app grows, we could have more services, such as UserService or OrderService, and dumping them all in src/app.ts would get messy fast.

A much cleaner, Fastify-idiomatic way is to create another plugin specifically for your services. This keeps things organized and lets you manage dependencies. This services plugin will:

  1. Depend on our prismaPlugin so we know fastify.prisma is ready when we create it.
  2. Create the DocumentService instance using fastify.prisma and fastify.log.
  3. Decorate the fastify instance with our service (fastify.documentService) so our routes can easily access it.

As we noted with the Prisma plugin, the beauty of using the @fastify/autoload plugin for our src/plugins directory is that we don't even need to touch src/app.ts again to get this new plugin registered. Autoload will automatically detect our services plugin, check its dependencies (prismaPlugin), and load it in the correct order.

Let's get that plugin out. Create a services.ts file under the src/plugins subdirectory:

touch src/plugins/services.ts

Add the following content to that file:

import fp from "fastify-plugin";
import { FastifyInstance, FastifyPluginAsync } from "fastify";
import {
  createDocumentService,
  DocumentService,
} from "../routes/documents/document.service";

declare module "fastify" {
  interface FastifyInstance {
documentService: DocumentService;
// Add other services here if needed
  }
}

const services: FastifyPluginAsync = fp(
  async (fastify: FastifyInstance) => {
const documentService = createDocumentService({
  logger: fastify.log,
  prisma: fastify.prisma,
});

fastify.decorate("documentService", documentService);

fastify.log.info("DocumentService registered");
  },
  {
name: "servicesPlugin",
dependencies: ["prismaPlugin"],
  }
);

export default services;

This new plugin follows the same patterns and uses the same APIs we used to create the Prisma plugin. What's new here is that we define a dependency that needs to be loaded before this plugin can be loaded.

⚠️ If you have had any issues until now, you can check the git repository's commit Commit 9.

Create Fastify Routes

For this application, we want to create endpoints to perform read and write operations on documents:

# get all documents (for the demo user)
GET /api/documents

# get a document using an id parameter (for the demo user)
GET /api/documents/:id

# create a document (for the demo user)
POST /api/documents

# update a document using an id parameter (for the demo user)
PUT /api/documents/:id

# delete a document using an id parameter (for the demo user)
DELETE /api/documents/:id

In this phase of the tutorial, before implementing authentication, you will simulate the user context within the router by using a hardcoded user ID. This ensures our service layer receives the necessary context without relying on insecure client input, such as passing the ownerId as a query parameter in the request.

As you can see, we must prefix our API routes with /api. You can do so easily by making a small update in the app function present in the src/app.ts file.

Locate the code that uses AutoLoad to load all the plugins defined in the routes directory and update it like so:

// This loads all plugins defined in routes
// define your routes in one of these
// eslint-disable-next-line no-void
void fastify.register(AutoLoad, {
  dir: join(__dirname, "routes"),
  options: { ...opts, prefix: "/api" },
});

You are adding the prefix option to its options object with the value that we need, /api, satisfying the requirements of the API design.

In Fastify, everything is a plugin, including routers that define your API endpoints. Implementing routes as a Fastify plugin provides the following benefits:

  • Encapsulation: A plugin groups related routes and logic within a single module.
  • Modularity: A plugin allows you to manage an application feature independently.
  • Shared Context: A plugin provides access to a decorated (enhanced) fastify instance, such as using fastify.documentService.
  • Now, let's create the basic structure of the documents router plugin:
touch src/routes/documents/index.ts

Then, populate that file with the following content:

import { FastifyPluginAsync } from "fastify";
import {
  CreateDocumentInput,
  jsonSchemas,
  UpdateDocumentInput,
} from "./document.zod";
import { ServiceErrorCode } from "../../models/service-error.model";

interface DocumentParams {
  id: string;
}

const DEFAULT_OWNER_ID = "0195ed4b-8d27-722f-a570-2ebd00cf9ce8";

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  // Add route definitions here
};

export default documents;

You define the DEFAULT_OWNER_ID constant within the plugin scope. This ID will be used to simulate the authenticated user context when calling the service layer. For a production application, you use an authenticated user's ID derived from a validated session or token.

Define the route to retrieve all documents for the simulated user:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
"/",
{ schema: jsonSchemas.getAllDocuments },
async (request, reply) => {
  // Simulate getting the ownerId from the auth context
  const ownerId = DEFAULT_OWNER_ID;

  const result = await fastify.documentService.findAll(ownerId);

  if (result.isErr()) {
fastify.log.error(
  { error: result.error, ownerId },
  "Error fetching all documents for user."
);

return reply.status(500).send(result.error.message);
  }

  reply.status(200).send(result.value);
}
  );

  // Add more route definitions here...
};

export default documents;

The fastify.get() method is a quick way to define a Fastify route that handles the GET method. It takes the route path, configuration options, and the route handler as arguments. All other REST methods have the same signature, such as fastify.post() or fastify.put().

You provide the Zod-generated JSON schema via jsonSchemas.getAllDocuments for response serialization. Inside the handler, you use our DEFAULT_OWNER_ID constant and pass it to fastify.documentService.findAll(). You access the injected service via the decorated fastify instance: fastify.documentService to findAll documents in the database. If the service returns an error (result.isErr()), you log it and send a 500 response. Otherwise, we will send a 200 OK with the documents.

Learn more about Fastify validation and serialization.

If this task fails, you throw the error that Fastify's centralized error handler will catch. On success, you reply with a 200 OK status code along with the array of documents, result.value.

The rest of the route will follow a similar pattern. So, let's define the route to retrieve a specific document by its ID next:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
"/",
{ schema: jsonSchemas.getAllDocuments },
async (request, reply) => {
  /* ... */
}
  );

fastify.get<{ Params: DocumentParams }>(
  "/:id",
  { schema: jsonSchemas.getDocument },
  async (request, reply) => {
  const id = request.params.id;
  // Simulate getting the ownerId from auth context
  const ownerId = DEFAULT_OWNER_ID;

  const result = await fastify.documentService.find(id, ownerId);

  if (result.isErr()) {
  if (result.error.code === ServiceErrorCode.NotFound) {
  fastify.log.warn(
  { id, ownerId, error: result.error },
  "Failed to find document for user"
  );

  return reply.status(404).send(result.error.message);
  }

  fastify.log.warn(
  { id, ownerId, error: result.error },
  "Error finding document for user."
  );

  return reply.status(500).send(result.error.message);
  }

  reply.status(200).send(result.value);
  },
  );

  // Add more route definitions here...
};

export default documents;

This route extracts the id from request.params and uses the DEFAULT_OWNER_ID when calling fastify.documentService.find(). It handles the NotFound service error specifically by returning a 404 status.

Next, define the route to create a new document for the simulated user:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
"/",
{ schema: jsonSchemas.getAllDocuments },
async (request, reply) => {
  /* ... */
}
  );
  fastify.get<{ Params: DocumentParams }>(
"/:id",
{ schema: jsonSchemas.getDocument },
async (request, reply) => {
  /* ... */
}
  );

  fastify.post<{ Body: CreateDocumentInput }>(
"/",
{ schema: jsonSchemas.createDocument },
async (request, reply) => {
  const documentData = request.body;
  const ownerId = DEFAULT_OWNER_ID;

  const serviceInput = {
...documentData,
ownerId: ownerId,
  };

  const result = await fastify.documentService.create(serviceInput);

  if (result.isErr()) {
// Handle potential conflict error from service
if (result.error.code === ServiceErrorCode.Conflict) {
  fastify.log.warn(
{ error: result.error, body: serviceInput },
"Conflict error creating a document for the user."
  );
  return reply.status(409).send(result.error.message);
}

fastify.log.error(
  { error: result.error, body: serviceInput },
  "Error creating a document for the user."
);

return reply.status(500).send(result.error.message);
  }

  reply.status(201).send(result.value);
}
  );

  // Add more route definitions here...
};

export default documents;

Here, request.body is validated against CreateDocumentSchema, which only includes title and content. You then create a serviceInput object, combining the client data with our simulated DEFAULT_OWNER_ID, before passing it to fastify.documentService.create(). You also handle the Conflict error with a 409 status.

With that in place, let's define the route to update an existing document for the simulated user:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
"/",
{ schema: jsonSchemas.getAllDocuments },
async (request, reply) => {
  /* ... */
}
  );
  fastify.get<{ Params: DocumentParams }>(
"/:id",
{ schema: jsonSchemas.getDocument },
async (request, reply) => {
  /* ... */
}
  );
  fastify.post<{ Body: CreateDocumentInput }>(
"/",
{ schema: jsonSchemas.createDocument },
async (request, reply) => {
  /* ... */
}
  );

  fastify.put<{ Params: DocumentParams; Body: UpdateDocumentInput }>(
"/:id",
{ schema: jsonSchemas.updateDocument },
async (request, reply) => {
  const id = request.params.id;
  const documentData = request.body;
  const ownerId = DEFAULT_OWNER_ID;

  const serviceInput = {
...documentData,
ownerId: ownerId,
  };

  const result = await fastify.documentService.update(id, serviceInput);

  if (result.isErr()) {
if (result.error.code === ServiceErrorCode.NotFound) {
  fastify.log.warn(
{ id, ownerId, error: result.error },
"Failed to update document (not found or wrong user)."
  );

  return reply.status(404).send(result.error.message);
}
// Handle potential conflict error from service
if (result.error.code === ServiceErrorCode.Conflict) {
  fastify.log.warn(
{ id, error: result.error, body: serviceInput },
"Conflict error updating document for the user."
  );

  return reply.status(409).send(result.error.message);
}

fastify.log.error(
  { id, ownerId, error: result.error, body: serviceInput },
  "Error updating document for user."
);

return reply.status(500).send(result.error.message);
  }

  reply.status(200).send(result.value);
}
  );

  // Add more route definitions here...
};

export default documents;

Similar to POST, the PUT handler combines the validated client data (request.body containing optional title/content) with the simulated DEFAULT_OWNER_ID into serviceInput. This object is then passed to fastify.documentService.update(), which uses the ownerId within serviceInput for its where clause check.

Finally, define the route to delete a document for the simulated user:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
"/",
{ schema: jsonSchemas.getAllDocuments },
async (request, reply) => {
  /* ... */
}
  );
  fastify.get<{ Params: DocumentParams }>(
"/:id",
{ schema: jsonSchemas.getDocument },
async (request, reply) => {
  /* ... */
}
  );
  fastify.post<{ Body: CreateDocumentInput }>(
"/",
{ schema: jsonSchemas.createDocument },
async (request, reply) => {
  /* ... */
}
  );
  fastify.put<{ Params: DocumentParams; Body: UpdateDocumentInput }>(
"/:id",
{ schema: jsonSchemas.updateDocument },
async (request, reply) => {
  /* ... */
}
  );

  fastify.delete<{ Params: DocumentParams }>(
"/:id",
{ schema: jsonSchemas.deleteDocument },
async (request, reply) => {
  const id = request.params.id;
  const ownerId = DEFAULT_OWNER_ID;

  const result = await fastify.documentService.remove(id, ownerId);

  if (result.isErr()) {
if (result.error.code === ServiceErrorCode.NotFound) {
  fastify.log.warn(
{ id, ownerId, error: result.error },
"Failed to delete a document (not found or wrong user)."
  );

  return reply.status(404).send(result.error.message);
}

fastify.log.error(
  { id, ownerId, error: result.error },
  "Error removing document for user."
);

return reply.status(500).send(result.error.message);
  }

  reply.status(204).send();
}
  );
};

export default documents;

The DELETE handler simply extracts the id from params and uses the DEFAULT_OWNER_ID when calling fastify.documentService.remove(). The schema correctly specifies no request body is expected.

By building this routes plugin, you've seen the following key Fastify concepts in action:

  • Request Validation: Zod schemas defined in jsonSchemas are used within the schema option of each route definition to automatically validate request parameters (Params) and bodies (Body). Invalid requests result in a 400 Bad Request response.
  • Response Serialization: The same schemas define the structure of the response (Reply), ensuring consistent output and providing performance benefits through Fastify's optimized serialization.
  • Service Layer Integration: Route handlers delegate business logic and data operations to the injected fastify.documentService.
  • Simulated User Context: For this unauthenticated phase, the router uses a hardcoded DEFAULT_OWNER_ID to provide the necessary user context to the service layer, ensuring security while preparing for real authentication.
  • Error Handling: Service layer methods return a Result. Route handlers check for errors (isErr()), log them, and map specific service errors (NotFound, Conflict) to appropriate HTTP status codes (404, 409), returning a generic 500 for other errors.

⚠️ If you have had any issues until now, you can check the git repository's commit Commit 10.

Error Handling

It's easy to fall into traps related to Cross-Origin Resource Sharing (CORS) errors when building a frontend application that needs to communicate with your Fastify backend, particularly when credentialed requests are involved, such as including Authorization headers.

While you already set up a CORS plugin on your entry point plugin, the @fastify/cors plugin adds CORS headers only to successful responses from defined routes and preflight (OPTIONS) responses. It does not automatically add CORS headers to responses generated by Fastify's internal default 404 (Not Found) handler, especially when using the Fastify CLI to run your application.

When the browser receives the 404 response lacking the Access-Control-Allow-Origin header, it blocks the response due to the CORS policy, even though the 404 status itself might be expected.

You can prevent this issue by implementing a custom 404 handler using fastify.setNotFoundHandler to ensure the necessary CORS headers are added to 404 responses when the request originates from an allowed origin. You also need to do the same for internal error responses generated by Fastify.

Add the following code after the registration of the Cors and Helmet plugins in your src/app.ts file and before Fastify registers the AutoLoad plugin:

fastify.setNotFoundHandler((request, reply) => {
  // Set CORS headers explicitly
  reply.header("Access-Control-Allow-Origin", CORS_ALLOWED_ORIGINS); // Or your origin
  // Add other necessary CORS headers (methods, headers, etc.) if needed

  reply.code(404).send({ message: "Resource not found" });
});

fastify.setErrorHandler((error, request, reply) => {
  // Log the error
  request.log.error(error);

  // Set CORS headers explicitly
  reply.header("Access-Control-Allow-Origin", CORS_ALLOWED_ORIGINS); // Or your origin
  // Add other necessary CORS headers if needed

  // Send generic error response
  reply.status(500).send({ message: "Internal Server Error" });
  // Or customize based on error type if needed
});

⚠️ If you have had any issues until now, you can check the git repository's commit Commit 11.

Test the Fastify API Endpoints

Ensure your server is running (npm run dev). You might need to re-seed your database (npm run db:seed) if you reset it earlier or made incompatible changes.

Use curl or a tool like Postman/Insomnia to test the endpoints.

Get All Documents (for Demo User)

curl -X GET "http://localhost:8080/api/documents"

Get a Specific Document (for Demo User)

Replace genai_doc_001 with an actual ID from your seeded data (e.g., 0195ed4b-8d29-7588-ae8d-7305e542c305).

curl -X GET "http://localhost:8080/api/documents/0195ed4b-8d29-7588-ae8d-7305e542c305"

Create a New Document (for Demo User)

curl -X POST "http://localhost:8080/api/documents" \
 -H "Content-Type: application/json" \
 -d '{
   "title": "GenAI Prompt Engineering Guide",
   "content": "Best practices for writing effective prompts for large language models."
 }'

Note: the server will automatically assign ownership to the demo user.

Update a Document (for Demo User)

Replace genai_doc_001 with an actual ID.

curl -X PUT "http://localhost:8080/api/documents/0195ed4b-8d29-7588-ae8d-7305e542c305" \
 -H "Content-Type: application/json" \
 -d '{
   "title": "Advanced GenAI Prompt Engineering",
   "content": "Advanced techniques for prompt chaining and few-shot learning in LLMs."
 }'

This only works if the document is owned by the demo user.

Delete a Document (for Demo User)

Replace genai_doc_001 with an actual ID.

curl -X DELETE "http://localhost:8080/api/documents/0195ed4b-8d29-7588-ae8d-7305e542c305"

This only works if the document is owned by the demo user. No request body is needed.

Test 404 Not Found

Try to get a document that does not exist:

curl -X GET "http://localhost:8080/api/documents/non_existent_doc_id"

Try to get a route that does not exist:

curl -X GET "http://localhost:8080/api/orders

Security Considerations

Currently, all API endpoints are public. You would typically implement authentication and authorization to properly secure the write operations (POST, PUT, DELETE). This involves:

  1. Integrating an identity provider like Auth0.
  2. Validating access tokens (e.g., JWTs) on incoming requests.
  3. Checking user permissions or roles before allowing sensitive operations.

Auth0 recently released a Fastify API SDK for handling JWTs and implementing authorization in your Fastify APIs: @auth0/auth0-fastify-api. Be sure to check it out.

Conclusion

You've built a robust CRUD API using Node.js, Fastify, and TypeScript. You've leveraged powerful tools and patterns:

  • Fastify: For high performance and a great developer experience.
  • TypeScript: For static typing and improved code maintainability.
  • Prisma: As a type-safe ORM for database interactions (with Prisma Studio for visualization).
  • Zod: As a single source of truth for validation and API data shapes.
  • neverthrow: For the service layer's functional and explicit error handling.
  • UUIDv7: For efficient internal IDs and user-friendly public identifiers.
  • Dependency Injection: Using Fastify decorators for cleaner service management.
  • Centralized Error Handling: Leveraging Fastify's setErrorHandler and setNotFoundHandler for consistent error responses.

This foundation provides a scalable and maintainable structure for building more complex features onto your API. Remember to explore testing strategies (unit, integration) and implement proper security measures for production deployment.

Happy coding!