Skip to content

Commit

Permalink
Implement RPC API handlers and client
Browse files Browse the repository at this point in the history
This introduces a new way of writing RPC styled API
handlers and abstract away the use of status codes, HTTP
responses etc. The input validation, type-cheking and OpenAPI
spec generation are handled in the same way as with the REST
endpoint handlers.

Additionally, a new RPC client function is introduced that allows
calling the procedures both on the server and in the client.
  • Loading branch information
blomqma committed Nov 15, 2023
1 parent 41fd830 commit a4a336e
Show file tree
Hide file tree
Showing 44 changed files with 1,831 additions and 665 deletions.
68 changes: 68 additions & 0 deletions apps/example/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,74 @@
"operationId": "deleteTodo",
"tags": ["example-api", "todos", "app-router"]
}
},
"/api/rpc": {
"post": {
"tags": ["RPC"],
"operationId": "rpc.call",
"description": "Call a remote procedure",
"parameters": [
{
"name": "X-Next-Rest-Framework-Procedure",
"in": "header",
"required": true
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"],
"additionalProperties": false
}
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "number" },
"name": { "type": "string" },
"completed": { "type": "boolean" }
},
"required": ["id", "name", "completed"],
"additionalProperties": false
}
},
{ "type": "string" }
]
}
}
}
},
"default": {
"description": "An unknown error occurred, trying again might help.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "message": { "type": "string" } }
}
}
}
}
}
}
}
}
}
104 changes: 104 additions & 0 deletions apps/example/src/app/api/routes/rpc/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { rpcOperation, rpcRouteHandler } from 'next-rest-framework';
import { z } from 'zod';

const TODOS = [
{
id: 1,
name: 'TODO 1',
completed: false
}
];

// Example App Router RPC handler.
export const POST = rpcRouteHandler({
getTodos: rpcOperation({
// Optional OpenAPI operation documentation.
operationId: 'getTodos',
tags: ['example-api', 'todos', 'app-router', 'rpc']
})
// Output schema for strictly-typed responses and OpenAPI documentation.
.output([
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']
})
.input(z.string())
.output([
z.object({
error: z.string()
}),
z.object({
id: z.number(),
name: z.string(),
completed: z.boolean()
})
])
.handler((id) => {
const todo = TODOS.find((t) => t.id === Number(id));

if (!todo) {
// Type-checked response.
return { error: 'TODO not found.' };
}

// Type-checked response.
return todo;
}),

createTodo: rpcOperation({
// Optional OpenAPI operation documentation.
operationId: 'createTodo',
tags: ['example-api', 'todos', 'app-router', 'rpc']
})
// 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() })])
.handler(async ({ name }) => {
// Type-checked response.
return { message: `New TODO created: ${name}` };
}),

deleteTodo: rpcOperation({
operationId: 'deleteTodo',
tags: ['example-api', 'todos', 'app-router', 'rpc']
})
.input(z.string())
.output([
z.object({ error: z.string() }),
z.object({ message: z.string() })
])
.handler((id) => {
// Delete todo.
const todo = TODOS.find((t) => t.id === Number(id));

if (!todo) {
// Type-checked response.
return {
error: 'TODO not found.'
};
}

// Type-checked response.
return { message: 'TODO deleted.' };
})
});

export type AppRouterRpcClient = typeof POST.client;
Empty file added apps/example/src/app/client.ts
Empty file.
6 changes: 5 additions & 1 deletion apps/example/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ export const metadata = {
description: 'Example application for Next REST Framework'
};

export default function Layout({ children }: { children: React.ReactNode }) {
export default async function Layout({
children
}: {
children: React.ReactNode;
}) {
const cookieStore = cookies();
const theme = cookieStore.get('theme')?.value ?? 'light';

Expand Down
13 changes: 13 additions & 0 deletions apps/example/src/app/rpc-client-example/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { rpcClient } from 'next-rest-framework/dist/client';
import { type AppRouterRpcClient } from '../api/routes/rpc/route';

// Works both on server and client.
const client = rpcClient<AppRouterRpcClient>({
url: 'http://localhost:3000/api/routes/rpc'
});

// Simple example - the client can be easily integrated with any data fetching framework, like React Query or RTKQ.
export default async function Page() {
const data = await client.getTodos();
return <>{JSON.stringify(data)}</>;
}
11 changes: 11 additions & 0 deletions apps/example/src/pages/api/api-routes/rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { rpcApiRouteHandler } from 'next-rest-framework';

// Example Pages Router RPC handler.
const handler = rpcApiRouteHandler({
// ...
// Exactly the same as the App Router example.
});

export default handler;

export type RpcApiRouteClient = typeof handler.client;
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
logInitInfo,
logNextRestFrameworkError,
getHtmlForDocs
} from '../utils';
} from '../shared';

export const docsRouteHandler = (_config?: NextRestFrameworkConfig) => {
const config = getConfig(_config);
Expand Down
2 changes: 2 additions & 0 deletions packages/next-rest-framework/src/app-router/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './docs-route-handler';
export * from './route-handler';
export * from './route-operation';
export * from './rpc-route-handler';
export { TypedNextResponse } from './typed-next-response';
29 changes: 23 additions & 6 deletions packages/next-rest-framework/src/app-router/route-handler.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { DEFAULT_ERRORS, NEXT_REST_FRAMEWORK_USER_AGENT } from '../constants';
import { type RouteParams, type BaseQuery } from '../types';
import { type BaseQuery } from '../types';
import {
getPathsFromMethodHandlers,
isValidMethod,
validateSchema,
logNextRestFrameworkError
} from '../utils';
} 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;
[ValidMethod.GET]?: RouteOperationDefinition;
[ValidMethod.PUT]?: RouteOperationDefinition;
[ValidMethod.POST]?: RouteOperationDefinition;
[ValidMethod.DELETE]?: RouteOperationDefinition;
[ValidMethod.OPTIONS]?: RouteOperationDefinition;
[ValidMethod.HEAD]?: RouteOperationDefinition;
[ValidMethod.PATCH]?: RouteOperationDefinition;
}

export const routeHandler = (methodHandlers: RouteParams) => {
const handler = async (req: NextRequest, context: { params: BaseQuery }) => {
Expand Down Expand Up @@ -91,7 +108,7 @@ ${error}`);
if (!valid) {
return NextResponse.json(
{
message: 'Invalid request body.',
message: DEFAULT_ERRORS.invalidRequestBody,
errors
},
{
Expand All @@ -102,7 +119,7 @@ ${error}`);
} catch (error) {
return NextResponse.json(
{
message: 'Missing request body.'
message: DEFAULT_ERRORS.missingRequestBody
},
{
status: 400
Expand All @@ -119,7 +136,7 @@ ${error}`);
if (!valid) {
return NextResponse.json(
{
message: 'Invalid query parameters.',
message: DEFAULT_ERRORS.invalidQueryParameters,
errors
},
{
Expand All @@ -131,7 +148,7 @@ ${error}`);
}

if (!handler) {
throw Error('Handler not found.');
throw Error(DEFAULT_ERRORS.handlerNotFound);
}

const res = await handler(req, context);
Expand Down
Loading

0 comments on commit a4a336e

Please sign in to comment.