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 16, 2023
1 parent 41fd830 commit ab3ea19
Show file tree
Hide file tree
Showing 42 changed files with 1,949 additions and 694 deletions.
134 changes: 134 additions & 0 deletions apps/example/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,81 @@
"tags": ["example-api", "todos", "pages-router"]
}
},
"/api/routes/rpc": {
"post": {
"description": "RPC endpoint",
"tags": ["RPC"],
"operationId": "rpcCall",
"parameters": [
{
"name": "X-RPC-Operation",
"in": "header",
"schema": { "type": "string" },
"required": true,
"description": "The RPC operation to call."
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"discriminator": {
"propertyName": "X-RPC-Operation",
"mapping": {
"getTodoById": "#/components/schemas/GetTodoByIdBody",
"createTodo": "#/components/schemas/CreateTodoBody",
"deleteTodo": "#/components/schemas/DeleteTodoBody"
}
},
"oneOf": [
"#/components/schemas/GetTodoByIdBody",
"#/components/schemas/CreateTodoBody",
"#/components/schemas/DeleteTodoBody"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"discriminator": {
"propertyName": "X-RPC-Operation",
"mapping": {
"getTodoById": "#/components/schemas/GetTodoByIdBody",
"createTodo": "#/components/schemas/CreateTodoBody",
"deleteTodo": "#/components/schemas/DeleteTodoBody"
}
},
"oneOf": [
"#/components/schemas/GetTodosResponse1",
"#/components/schemas/GetTodoByIdResponse1",
"#/components/schemas/GetTodoByIdResponse2",
"#/components/schemas/CreateTodoResponse1",
"#/components/schemas/DeleteTodoResponse1",
"#/components/schemas/DeleteTodoResponse2"
]
}
}
}
},
"default": {
"description": "An unknown error occurred, trying again might help.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": { "message": { "type": "string" } }
}
}
}
}
}
}
},
"/api/routes/todos": {
"get": {
"responses": {
Expand Down Expand Up @@ -298,5 +373,64 @@
"tags": ["example-api", "todos", "app-router"]
}
}
},
"components": {
"schemas": {
"CreateTodoBody": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"],
"additionalProperties": false
},
"CreateTodoResponse1": {
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
},
"DeleteTodoBody": { "type": "string" },
"DeleteTodoResponse1": {
"type": "object",
"properties": { "error": { "type": "string" } },
"required": ["error"],
"additionalProperties": false
},
"DeleteTodoResponse2": {
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
},
"GetTodoByIdBody": { "type": "string" },
"GetTodoByIdResponse1": {
"type": "object",
"properties": { "error": { "type": "string" } },
"required": ["error"],
"additionalProperties": false
},
"GetTodoByIdResponse2": {
"type": "object",
"properties": {
"id": { "type": "number" },
"name": { "type": "string" },
"completed": { "type": "boolean" }
},
"required": ["id", "name", "completed"],
"additionalProperties": false
},
"GetTodosResponse1": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "number" },
"name": { "type": "string" },
"completed": { "type": "boolean" }
},
"required": ["id", "name", "completed"],
"additionalProperties": false
}
}
}
}
}
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;
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 @@ -2,13 +2,13 @@ import { type NextRequest, NextResponse } from 'next/server';
import { DEFAULT_ERRORS, NEXT_REST_FRAMEWORK_USER_AGENT } from '../constants';
import { type BaseQuery, type NextRestFrameworkConfig } from '../types';
import {
generatePathsFromDev,
fetchOasDataFromDev,
getConfig,
syncOpenApiSpec,
logInitInfo,
logNextRestFrameworkError,
getHtmlForDocs
} from '../utils';
} from '../shared';

export const docsRouteHandler = (_config?: NextRestFrameworkConfig) => {
const config = getConfig(_config);
Expand Down Expand Up @@ -38,8 +38,13 @@ export const docsRouteHandler = (_config?: NextRestFrameworkConfig) => {
}

if (config.autoGenerateOpenApiSpec) {
const paths = await generatePathsFromDev({ config, baseUrl, url });
await syncOpenApiSpec({ config, paths });
const nrfOasData = await fetchOasDataFromDev({
config,
baseUrl,
url
});

await syncOpenApiSpec({ config, nrfOasData });
}
}

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';
Loading

0 comments on commit ab3ea19

Please sign in to comment.