Skip to content

Commit

Permalink
Documentation updates (#1612)
Browse files Browse the repository at this point in the history
* Update Error Handling in SOFA

Mention error extensions

* ..

* Better

* Add information about OpenAPI
  • Loading branch information
ardatan authored Dec 19, 2024
1 parent bb256a5 commit 5daf648
Show file tree
Hide file tree
Showing 4 changed files with 269 additions and 32 deletions.
27 changes: 24 additions & 3 deletions example/collections.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createGraphQLError } from 'graphql-yoga';

const pizzas = [
{ id: 1, dough: 'pan', toppings: ['cheese'] },
{ id: 2, dough: 'classic', toppings: ['ham'] },
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
20 changes: 10 additions & 10 deletions tests/repro.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand All @@ -159,9 +159,9 @@ test('error extensions', async () => {
extensions: {
code: 'ACCOUNT_NOT_FOUND',
},
path: ['me']
}
]
path: ['me'],
},
],
});
}
})
});
190 changes: 187 additions & 3 deletions website/src/pages/docs/api/error-handler.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,197 @@ 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' },
});
},
})
);
```

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',
},
],
},
});
```
64 changes: 48 additions & 16 deletions website/src/pages/docs/recipes/open-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
})
```

0 comments on commit 5daf648

Please sign in to comment.