Skip to content

Commit

Permalink
Improve OpenAPI generation
Browse files Browse the repository at this point in the history
This change improves the OpenAPI spec
generation by introducing auto-generated
schema objects that can be used across
different operations.

Additionally, swagger-cli is introduced as
part of the CI pipeline to validate that the
generated OpenAPI document is valid.
  • Loading branch information
blomqma committed Nov 19, 2023
1 parent 0343e9a commit e86d7dc
Show file tree
Hide file tree
Showing 19 changed files with 844 additions and 393 deletions.
275 changes: 157 additions & 118 deletions apps/example/public/openapi.json

Large diffs are not rendered by default.

64 changes: 28 additions & 36 deletions apps/example/src/app/api/routes/rpc/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,40 +11,39 @@ const TODOS = [

// Example App Router RPC handler.
export const POST = rpcRouteHandler({
getTodos: rpcOperation({
// Optional OpenAPI operation documentation.
operationId: 'getTodos',
tags: ['example-api', 'todos', 'app-router', 'rpc']
})
getTodos: rpcOperation()
// Output schema for strictly-typed responses and OpenAPI documentation.
.output([
z.array(
z.object({
id: z.number(),
name: z.string(),
completed: z.boolean()
})
)
{
schema: z.array(
z.object({
id: z.number(),
name: z.string(),
completed: z.boolean()
})
)
}
])
.handler(() => {
// Type-checked response.
return TODOS;
}),

getTodoById: rpcOperation({
operationId: 'getTodoById',
tags: ['example-api', 'todos', 'app-router', 'rpc']
})
getTodoById: rpcOperation()
.input(z.string())
.output([
z.object({
error: z.string()
}),
z.object({
id: z.number(),
name: z.string(),
completed: z.boolean()
})
{
schema: z.object({
error: z.string()
})
},
{
schema: z.object({
id: z.number(),
name: z.string(),
completed: z.boolean()
})
}
])
.handler((id) => {
const todo = TODOS.find((t) => t.id === Number(id));
Expand All @@ -58,32 +57,25 @@ export const POST = rpcRouteHandler({
return todo;
}),

createTodo: rpcOperation({
// Optional OpenAPI operation documentation.
operationId: 'createTodo',
tags: ['example-api', 'todos', 'app-router', 'rpc']
})
createTodo: rpcOperation()
// Input schema for strictly-typed request, request validation and OpenAPI documentation.
.input(
z.object({
name: z.string()
})
)
// Output schema for strictly-typed responses and OpenAPI documentation.
.output([z.object({ message: z.string() })])
.output([{ schema: z.object({ message: z.string() }) }])
.handler(async ({ name }) => {
// Type-checked response.
return { message: `New TODO created: ${name}` };
}),

deleteTodo: rpcOperation({
operationId: 'deleteTodo',
tags: ['example-api', 'todos', 'app-router', 'rpc']
})
deleteTodo: rpcOperation()
.input(z.string())
.output([
z.object({ error: z.string() }),
z.object({ message: z.string() })
{ schema: z.object({ error: z.string() }) },
{ schema: z.object({ message: z.string() }) }
])
.handler((id) => {
// Delete todo.
Expand Down
6 changes: 4 additions & 2 deletions apps/example/src/app/api/routes/todos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ const handler = routeHandler({
{
status: 201,
contentType: 'application/json',
schema: z.string()
schema: z.string(),
name: 'CreateTodoSuccessResponse' // Optional name for OpenAPI spec.
},
{
status: 401,
contentType: 'application/json',
schema: z.string()
schema: z.string(),
name: 'CreateTodoUnauthorizedResponse' // Optional name for OpenAPI spec.
}
])
// Optional middleware logic executed before request validation.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
"test:watch": "pnpm --filter next-rest-framework run test:watch",
"type-check": "pnpm run -r type-check",
"format": "prettier --write '**/*.{ts,json}' && eslint --fix --max-warnings=0 --ext=.ts .",
"lint": "prettier --check '**/*.{ts,json}' && eslint --max-warnings=0 --ext=.ts .",
"lint": "prettier --check '**/*.{ts,json}' && eslint --max-warnings=0 --ext=.ts . && swagger-cli validate ./apps/example/public/openapi.json",
"ci": "pnpm run build && pnpm run type-check && pnpm run lint && pnpm run test"
},
"dependencies": {
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@apidevtools/swagger-cli": "4.0.4",
"@types/node": "20.5.4",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
Expand Down
14 changes: 6 additions & 8 deletions packages/next-rest-framework/src/app-router/route-handler.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { DEFAULT_ERRORS, NEXT_REST_FRAMEWORK_USER_AGENT } from '../constants';
import { type BaseQuery } from '../types';
import { type OpenApiPathItem, type BaseQuery } from '../types';
import {
getPathsFromMethodHandlers,
getOasDataFromMethodHandlers,
isValidMethod,
validateSchema,
logNextRestFrameworkError
} from '../shared';

import { type ValidMethod } from '../constants';

import { type OpenAPIV3_1 } from 'openapi-types';

import { type RouteOperationDefinition } from './route-operation';

export interface RouteParams {
openApiPath?: OpenAPIV3_1.PathItemObject;
openApiPath?: OpenApiPathItem;
[ValidMethod.GET]?: RouteOperationDefinition;
[ValidMethod.PUT]?: RouteOperationDefinition;
[ValidMethod.POST]?: RouteOperationDefinition;
Expand Down Expand Up @@ -54,7 +52,7 @@ export const routeHandler = (methodHandlers: RouteParams) => {
const route = decodeURIComponent(pathname ?? '');

try {
const nrfOasData = getPathsFromMethodHandlers({
const nrfOasData = getOasDataFromMethodHandlers({
methodHandlers,
route
});
Expand All @@ -72,7 +70,7 @@ ${error}`);
return handleMethodNotAllowed();
}

const { input, handler, middleware } = methodHandler._config;
const { input, handler, middleware } = methodHandler._meta;

if (middleware) {
const res = await middleware(new NextRequest(req.clone()), context);
Expand Down Expand Up @@ -163,7 +161,7 @@ ${error}`);
};

handler.getPaths = (route: string) =>
getPathsFromMethodHandlers({
getOasDataFromMethodHandlers({
methodHandlers,
route
});
Expand Down
12 changes: 6 additions & 6 deletions packages/next-rest-framework/src/app-router/route-operation.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/* eslint-disable @typescript-eslint/no-invalid-void-type */

import { type OpenAPIV3_1 } from 'openapi-types';
import {
type BaseStatus,
type BaseQuery,
type InputObject,
type OutputObject,
type BaseContentType,
type Modify,
type AnyCase
type AnyCase,
type OpenApiOperation
} from '../types';
import { type NextRequest, type NextResponse } from 'next/server';
import { type z } from 'zod';
Expand Down Expand Up @@ -216,16 +216,16 @@ type NextRouteHandler = (
) => Promise<NextResponse> | NextResponse | Promise<void> | void;

export interface RouteOperationDefinition {
_config: {
openApiOperation?: OpenAPIV3_1.OperationObject;
_meta: {
openApiOperation?: OpenApiOperation;
input?: InputObject;
output?: readonly OutputObject[];
middleware?: NextRouteHandler;
handler?: NextRouteHandler;
};
}

type RouteOperation = (openApiOperation?: OpenAPIV3_1.OperationObject) => {
type RouteOperation = (openApiOperation?: OpenApiOperation) => {
input: RouteInput<true>;
output: RouteOutput<true>;
middleware: (middleware?: RouteHandler) => {
Expand All @@ -241,7 +241,7 @@ export const routeOperation: RouteOperation = (openApiOperation) => {
middleware: Middleware | undefined,
handler: Handler | undefined
): RouteOperationDefinition => ({
_config: {
_meta: {
openApiOperation,
input,
output,
Expand Down
11 changes: 9 additions & 2 deletions packages/next-rest-framework/src/app-router/rpc-route-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ import {
} from '../shared';
import { type Client } from '../client/rpc-client';
import { type OperationDefinition } from '../shared/rpc-operation';
import { type OpenApiOperation, type OpenApiPathItem } from '../types';

export const rpcRouteHandler = <
T extends Record<string, OperationDefinition<any, any>>
>(
operations: T
operations: T,
options?: {
openApiPath?: OpenApiPathItem;
openApiOperation?: OpenApiOperation;
}
) => {
const handler = async (req: NextRequest) => {
try {
Expand Down Expand Up @@ -43,7 +48,8 @@ export const rpcRouteHandler = <
try {
const nrfOasData = getOasDataFromRpcOperations({
operations,
route
route,
options
});

return NextResponse.json({ nrfOasData }, { status: 200 });
Expand Down Expand Up @@ -136,6 +142,7 @@ ${error}`);
handler.getPaths = (route: string) =>
getOasDataFromRpcOperations({
operations,
options,
route
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import {
type ValidMethod
} from '../constants';
import {
getPathsFromMethodHandlers,
getOasDataFromMethodHandlers,
isValidMethod,
validateSchema,
logNextRestFrameworkError
} from '../shared';
import { type NextApiRequest, type NextApiResponse } from 'next/types';
import { type OpenAPIV3_1 } from 'openapi-types';
import { type ApiRouteOperationDefinition } from './api-route-operation';
import { type OpenApiPathItem } from '../types';

export interface ApiRouteParams {
openApiPath?: OpenAPIV3_1.PathItemObject;
openApiPath?: OpenApiPathItem;
[ValidMethod.GET]?: ApiRouteOperationDefinition;
[ValidMethod.PUT]?: ApiRouteOperationDefinition;
[ValidMethod.POST]?: ApiRouteOperationDefinition;
Expand Down Expand Up @@ -46,7 +46,7 @@ export const apiRouteHandler = (methodHandlers: ApiRouteParams) => {
const route = decodeURIComponent(pathname ?? '');

try {
const nrfOasData = getPathsFromMethodHandlers({
const nrfOasData = getOasDataFromMethodHandlers({
methodHandlers,
route
});
Expand All @@ -66,7 +66,7 @@ ${error}`);
return;
}

const { input, handler, middleware } = methodHandler._config;
const { input, handler, middleware } = methodHandler._meta;

if (middleware) {
await middleware(req, res);
Expand Down Expand Up @@ -132,7 +132,7 @@ ${error}`);
};

handler.getPaths = (route: string) =>
getPathsFromMethodHandlers({
getOasDataFromMethodHandlers({
methodHandlers,
route
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import {
type AnyCase,
type BaseQuery,
type BaseStatus,
type BaseContentType
type BaseContentType,
type OpenApiOperation
} from '../types';
import {
type NextApiRequest,
type NextApiHandler,
type NextApiResponse
} from 'next/types';
import { type OpenAPIV3_1 } from 'openapi-types';
import { type z } from 'zod';

type TypedNextApiRequest<Body, Query> = Modify<
Expand Down Expand Up @@ -148,16 +148,16 @@ type ApiRouteInput<Middleware extends boolean = false> = <
: Record<string, unknown>);

export interface ApiRouteOperationDefinition {
_config: {
openApiOperation?: OpenAPIV3_1.OperationObject;
_meta: {
openApiOperation?: OpenApiOperation;
input?: InputObject;
output?: readonly OutputObject[];
middleware?: NextApiHandler;
handler?: NextApiHandler;
};
}

type ApiRouteOperation = (openApiOperation?: OpenAPIV3_1.OperationObject) => {
type ApiRouteOperation = (openApiOperation?: OpenApiOperation) => {
input: ApiRouteInput<true>;
output: ApiRouteOutput<true>;
middleware: (middleware?: ApiRouteHandler) => {
Expand All @@ -173,7 +173,7 @@ export const apiRouteOperation: ApiRouteOperation = (openApiOperation) => {
middleware: Middleware | undefined,
handler: Handler | undefined
): ApiRouteOperationDefinition => ({
_config: {
_meta: {
openApiOperation,
input,
output,
Expand Down
Loading

0 comments on commit e86d7dc

Please sign in to comment.