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:
- Depend on our
prismaPlugin
so we knowfastify.prisma
is ready when we create it. - Create the
DocumentService
instance usingfastify.prisma
andfastify.log
. - 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 usingfastify.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 theschema
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:
- Integrating an identity provider like Auth0.
- Validating access tokens (e.g., JWTs) on incoming requests.
- 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
andsetNotFoundHandler
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!
About the author
Dan Arias
Software Engineer (Auth0 Alumni)