diff --git a/example/collections.ts b/example/collections.ts index 7a10183e..ac6bb492 100644 --- a/example/collections.ts +++ b/example/collections.ts @@ -1,3 +1,5 @@ +import { createGraphQLError } from 'graphql-yoga'; + const pizzas = [ { id: 1, dough: 'pan', toppings: ['cheese'] }, { id: 2, dough: 'classic', toppings: ['ham'] }, @@ -43,7 +45,17 @@ export const UsersCollection = { get(id: string | number) { const uid = typeof id === 'string' ? parseInt(id, 10) : id; - return users.find((u) => u.id === uid); + const user = users.find((u) => u.id === uid); + if (!user) { + return createGraphQLError('User not found', { + extensions: { + http: { + status: 404, + }, + }, + }); + } + return user; }, all() { return users; @@ -53,8 +65,17 @@ export const UsersCollection = { export const BooksCollection = { get(id: string | number) { const bid = typeof id === 'string' ? parseInt(id, 10) : id; - - return books.find((u) => u.id === bid); + const book = books.find((u) => u.id === bid); + if (!book) { + return createGraphQLError('Book not found', { + extensions: { + http: { + status: 404, + }, + }, + }); + } + return book; }, all() { return books; diff --git a/tests/repro.spec.ts b/tests/repro.spec.ts index 49f53d99..06a7359b 100644 --- a/tests/repro.spec.ts +++ b/tests/repro.spec.ts @@ -136,16 +136,16 @@ test('error extensions', async () => { resolvers: { Query: { me: () => { - throw new GraphQLError("account not found", { + throw new GraphQLError('account not found', { extensions: { - code: "ACCOUNT_NOT_FOUND", - http: { status: 404 } - } + code: 'ACCOUNT_NOT_FOUND', + http: { status: 404 }, + }, }); }, }, - } - }) + }, + }), }); for (let i = 0; i < 10; i++) { @@ -159,9 +159,9 @@ test('error extensions', async () => { extensions: { code: 'ACCOUNT_NOT_FOUND', }, - path: ['me'] - } - ] + path: ['me'], + }, + ], }); } -}) \ No newline at end of file +}); diff --git a/website/src/pages/docs/api/error-handler.mdx b/website/src/pages/docs/api/error-handler.mdx index 63844484..4cf749f7 100644 --- a/website/src/pages/docs/api/error-handler.mdx +++ b/website/src/pages/docs/api/error-handler.mdx @@ -11,9 +11,12 @@ api.use( '/api', useSofa({ schema, - errorHandler(errs) { - logErrors(errors); - return new Response(formatError(errs[0]), { + // `errors` is the array containing the `Error` objects + errorHandler(errors) { + for (const error of errors) { + console.error(`Error: ${error.message}`); + } + return new Response(errs[0].message, { status: 500, headers: { 'Content-Type': 'application/json' }, }); @@ -21,3 +24,184 @@ api.use( }) ); ``` + +By default, it always returns a response with `200` if the request is valid. +If the request is invalid, it returns a response with `400` status code and the error message. + +```ts +const res = await fetch('http://localhost:4000/api/createUser', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 1, // Invalid name + }), +}); + +console.log(res.status); // 400 +const data = await res.json(); +console.log(data); // {"errors":[{"message":"Expected type String, found 1."}]} +``` + +## HTTP Error Extensions + +Just like GraphQL Yoga's [error handling](https://the-guild.dev/graphql/yoga-server/docs/features/error-masking#modifying-http-status-codes-and-headers) , SOFA respects the status code and headers provided in the error extensions. + +```ts filename="GraphQL Error with http extensions." {6-11} +throw new GraphQLError( + `User with id '${args.byId}' not found.`, + // error extensions + { + extensions: { + http: { + status: 400, + headers: { + 'x-custom-header': 'some-value', + }, + }, + }, + } +); +``` + +In this case, you returns a response with `400` status code and `x-custom-header` in the response headers. + +Let's say you have a simple GraphQL API like below; + +```ts +import { createServer } from 'node:http'; +import { useSofa } from 'sofa-api'; +import { makeExecutableSchema } from '@graphql-tools/schema'; + +createServer( + useSofa({ + basePath: '/api', + schema: makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + posts: [Post!]! + } + type Post { + id: ID! + title: String! + secret: String! + } + `, + resolvers: { + Query: { + posts() { + return getPosts(); + }, + }, + Post: { + async secret(_, __, { request }) { + const authHeader = request.headers.get('Authorization'); + if (!authHeader) { + throw new GraphQLError('Unauthorized', { + extensions: { + http: { + status: 401, + headers: { + 'WWW-Authenticate': 'Bearer', + }, + }, + }, + }); + } + const [type, token] = authHeader.split(' '); + if (type !== 'Bearer') { + throw new GraphQLError('Invalid token type', { + extensions: { + http: { + status: 401, + headers: { + 'WWW-Authenticate': 'Bearer', + }, + }, + }, + }); + } + if (token !== 'secret') { + throw new GraphQLError('Invalid token', { + extensions: { + http: { + status: 401, + headers: { + 'WWW-Authenticate': 'Bearer', + }, + }, + }, + }); + } + return 'Secret value'; + }, + }, + }, + }), + }) +).listen(4000); +``` + +In this case if you make a request to `/api/posts` without a valid `Authorization` header, +you will get a response with `401` status code and `WWW-Authenticate` in the response headers. +But the response body will contain the data and errors. + +```ts +const res = await fetch('http://localhost:4000/api/posts'); +console.log(res.status); // 401 +console.log(res.headers.get('WWW-Authenticate')); // Bearer +const data = await res.json(); +expect(data).toEqual({ + data: { + posts: [ + { + id: '1', + title: 'Post 1', + secret: null, + }, + { + id: '2', + title: 'Post 2', + secret: null, + }, + ], + }, + errors: [ + { + message: 'Unauthorized', + path: ['posts', 'secret'], + }, + ], +}); +``` + +In this case only errored fields will be `null` in the response body. + +However if you make a request to `/api/me` with `x-user-id` header, you will get a response with `200` status code and `x-custom-header` in the response headers. + +```ts +const res = await fetch('http://localhost:4000/api/posts', { + headers: { + Authorization: 'Bearer secret', + }, +}); +console.log(res.status); // 200 +const data = await res.json(); +expect(data).toEqual({ + data: { + posts: [ + { + id: '1', + title: 'Post 1', + secret: 'Secret value', + }, + { + id: '2', + title: 'Post 2', + secret: 'Secret value', + }, + ], + }, +}); +``` diff --git a/website/src/pages/docs/recipes/open-api.mdx b/website/src/pages/docs/recipes/open-api.mdx index 9b7373bb..dda88967 100644 --- a/website/src/pages/docs/recipes/open-api.mdx +++ b/website/src/pages/docs/recipes/open-api.mdx @@ -9,23 +9,55 @@ Thanks to GraphQL's Type System Sofa is able to generate OpenAPI (Swagger) defin ```ts import { useSofa } from 'sofa-api'; -app.use( - '/api', - useSofa({ - schema, - basePath: '/', - openAPI: { - info: { - title: 'Example API', - version: '3.0.0', - } - endpoint: '/openapi.json', - }, - swaggerUI: { - path: '/docs', +useSofa({ + schema, + basePath: '/api', + openAPI: { + info: { + title: 'Example API', + version: '3.0.0', } - }) -); + endpoint: '/openapi.json', + }, + swaggerUI: { + path: '/docs', + } +}) ``` > You can find swagger definitions in `/api/openapi.json` route. + +## Extending OpenAPI + +If you want to extend your OpenAPI document with [security schemes etc](https://swagger.io/docs/specification/v3_0/authentication/). +You can use `openAPI` option like below; + +```ts +import { useSofa } from 'sofa-api'; + +useSofa({ + schema, + basePath: '/api', + openAPI: { + info: { + title: 'Example API', + version: '3.0.0', + } + endpoint: '/openapi.json', + servers: [{ url: 'https://my-production.com', description: 'Production' }], + components: { + securitySchemes: { + bearerAuthorization: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + name: 'Authorization', + }, + }, + }, + }, + swaggerUI: { + path: '/docs', + } +}) +``` \ No newline at end of file