diff --git a/apps/example/public/openapi.json b/apps/example/public/openapi.json index c29d62c..5748996 100644 --- a/apps/example/public/openapi.json +++ b/apps/example/public/openapi.json @@ -6,15 +6,16 @@ "version": "v4.0.0" }, "paths": { - "/api/api-routes/todos": { + "/api/v1/todos": { "get": { + "operationId": "getTodos", "responses": { "200": { "description": "Response for status 200", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetTodosResponseBody" + "$ref": "#/components/schemas/GetTodos200ResponseBody" } } } @@ -28,14 +29,14 @@ } } }, - "operationId": "getTodos", "tags": ["example-api", "todos", "pages-router"] }, "post": { + "operationId": "createTodo", "requestBody": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PostTodosRequestBody" } + "schema": { "$ref": "#/components/schemas/CreateTodoRequestBody" } } } }, @@ -45,7 +46,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostTodosResponseBody" + "$ref": "#/components/schemas/CreateTodo201ResponseBody" } } } @@ -55,7 +56,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostTodosErrorResponseBody" + "$ref": "#/components/schemas/CreateTodo401ResponseBody" } } } @@ -69,19 +70,19 @@ } } }, - "operationId": "createTodo", "tags": ["example-api", "todos", "pages-router"] } }, - "/api/api-routes/todos/{id}": { + "/api/v1/todos/{id}": { "get": { + "operationId": "getTodoById", "responses": { "200": { "description": "Response for status 200", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetTodosResponseBody" + "$ref": "#/components/schemas/GetTodoById200ResponseBody" } } } @@ -91,7 +92,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetTodosErrorResponseBody" + "$ref": "#/components/schemas/GetTodoById404ResponseBody" } } } @@ -113,17 +114,17 @@ "schema": { "type": "string" } } ], - "operationId": "getTodoById", "tags": ["example-api", "todos", "pages-router"] }, "delete": { + "operationId": "deleteTodo", "responses": { "204": { "description": "Response for status 204", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteTodosResponseBody" + "$ref": "#/components/schemas/DeleteTodo204ResponseBody" } } } @@ -133,7 +134,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteTodosErrorResponseBody" + "$ref": "#/components/schemas/DeleteTodo404ResponseBody" } } } @@ -155,67 +156,94 @@ "schema": { "type": "string" } } ], - "operationId": "deleteTodo", "tags": ["example-api", "todos", "pages-router"] } }, - "/api/routes/rpc": { + "/api/v2/rpc/createTodo": { "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." + "operationId": "createTodo", + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateTodoRequestBody" } + } } - ], + }, + "responses": { + "200": { + "description": "CreateTodoResponseBody", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTodoResponseBody" + } + } + } + }, + "500": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + }, + "tags": ["RPC"] + } + }, + "/api/v2/rpc/deleteTodo": { + "post": { + "operationId": "deleteTodo", + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteTodoRequestBody" } + } + } + }, + "responses": { + "200": { + "description": "DeleteTodoResponseBody2", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTodoResponseBody2" + } + } + } + }, + "500": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + }, + "tags": ["RPC"] + } + }, + "/api/v2/rpc/getTodoById": { + "post": { + "operationId": "getTodoById", "requestBody": { "content": { "application/json": { "schema": { - "discriminator": { - "propertyName": "X-RPC-Operation", - "mapping": { - "getTodoById": "#/components/schemas/GetTodoByIdBody", - "createTodo": "#/components/schemas/CreateTodoBody", - "deleteTodo": "#/components/schemas/DeleteTodoBody" - } - }, - "oneOf": [ - { "$ref": "#/components/schemas/GetTodoByIdBody" }, - { "$ref": "#/components/schemas/CreateTodoBody" }, - { "$ref": "#/components/schemas/DeleteTodoBody" } - ] + "$ref": "#/components/schemas/GetTodoByIdRequestBody" } } } }, "responses": { "200": { - "description": "Successful response", + "description": "GetTodoByIdResponseBody2", "content": { "application/json": { "schema": { - "discriminator": { - "propertyName": "X-RPC-Operation", - "mapping": { - "getTodoById": "#/components/schemas/GetTodoByIdBody", - "createTodo": "#/components/schemas/CreateTodoBody", - "deleteTodo": "#/components/schemas/DeleteTodoBody" - } - }, - "oneOf": [ - { "$ref": "#/components/schemas/GetTodosResponse" }, - { "$ref": "#/components/schemas/GetTodoByIdResponse" }, - { "$ref": "#/components/schemas/GetTodoByIdResponse2" }, - { "$ref": "#/components/schemas/CreateTodoResponse" }, - { "$ref": "#/components/schemas/DeleteTodoResponse" }, - { "$ref": "#/components/schemas/DeleteTodoResponse2" } - ] + "$ref": "#/components/schemas/GetTodoByIdResponseBody2" } } } @@ -228,14 +256,16 @@ } } } - } + }, + "tags": ["RPC"] } }, - "/api/routes/todos": { - "get": { + "/api/v2/rpc/getTodos": { + "post": { + "operationId": "getTodos", "responses": { "200": { - "description": "Response for status 200", + "description": "GetTodosResponseBody", "content": { "application/json": { "schema": { @@ -253,14 +283,40 @@ } } }, + "tags": ["RPC"] + } + }, + "/api/v2/todos": { + "get": { "operationId": "getTodos", + "responses": { + "200": { + "description": "Response for status 200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTodos200ResponseBody" + } + } + } + }, + "500": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + }, "tags": ["example-api", "todos", "app-router"] }, "post": { + "operationId": "createTodo", "requestBody": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/PostTodosRequestBody" } + "schema": { "$ref": "#/components/schemas/CreateTodoRequestBody" } } } }, @@ -270,7 +326,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostTodosResponseBody" + "$ref": "#/components/schemas/CreateTodo201ResponseBody" } } } @@ -280,7 +336,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostTodosErrorResponseBody" + "$ref": "#/components/schemas/CreateTodo401ResponseBody" } } } @@ -294,19 +350,19 @@ } } }, - "operationId": "createTodo", "tags": ["example-api", "todos", "app-router"] } }, - "/api/routes/todos/{id}": { + "/api/v2/todos/{id}": { "get": { + "operationId": "getTodoById", "responses": { "200": { "description": "Response for status 200", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetTodosResponseBody" + "$ref": "#/components/schemas/GetTodoById200ResponseBody" } } } @@ -316,7 +372,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetTodosErrorResponseBody" + "$ref": "#/components/schemas/GetTodoById404ResponseBody" } } } @@ -338,17 +394,17 @@ "schema": { "type": "string" } } ], - "operationId": "getTodoById", "tags": ["example-api", "todos", "app-router"] }, "delete": { + "operationId": "deleteTodo", "responses": { "204": { "description": "Response for status 204", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteTodosResponseBody" + "$ref": "#/components/schemas/DeleteTodo204ResponseBody" } } } @@ -358,7 +414,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteTodosErrorResponseBody" + "$ref": "#/components/schemas/DeleteTodo404ResponseBody" } } } @@ -380,48 +436,64 @@ "schema": { "type": "string" } } ], - "operationId": "deleteTodo", "tags": ["example-api", "todos", "app-router"] } } }, "components": { "schemas": { - "CreateTodoBody": { + "CreateTodo201ResponseBody": { "type": "string" }, + "CreateTodo401ResponseBody": { "type": "string" }, + "CreateTodoRequestBody": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"], "additionalProperties": false }, - "CreateTodoResponse": { + "CreateTodoResponseBody": { "type": "object", - "properties": { "message": { "type": "string" } }, - "required": ["message"], + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" }, + "completed": { "type": "boolean" } + }, + "required": ["id", "name", "completed"], "additionalProperties": false }, - "DeleteTodoBody": { "type": "string" }, - "DeleteTodoResponse": { + "DeleteTodo204ResponseBody": { "type": "string" }, + "DeleteTodo404ResponseBody": { "type": "string" }, + "DeleteTodoRequestBody": { "type": "string" }, + "DeleteTodoResponseBody": { "type": "object", "properties": { "error": { "type": "string" } }, "required": ["error"], "additionalProperties": false }, - "DeleteTodoResponse2": { + "DeleteTodoResponseBody2": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"], "additionalProperties": false }, - "DeleteTodosErrorResponseBody": { "type": "string" }, - "DeleteTodosResponseBody": { "type": "string" }, - "GetTodoByIdBody": { "type": "string" }, - "GetTodoByIdResponse": { + "GetTodoById200ResponseBody": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" }, + "completed": { "type": "boolean" } + }, + "required": ["id", "name", "completed"], + "additionalProperties": false + }, + "GetTodoById404ResponseBody": { "type": "string" }, + "GetTodoByIdRequestBody": { "type": "string" }, + "GetTodoByIdResponseBody": { "type": "object", "properties": { "error": { "type": "string" } }, "required": ["error"], "additionalProperties": false }, - "GetTodoByIdResponse2": { + "GetTodoByIdResponseBody2": { "type": "object", "properties": { "id": { "type": "number" }, @@ -431,8 +503,7 @@ "required": ["id", "name", "completed"], "additionalProperties": false }, - "GetTodosErrorResponseBody": { "type": "string" }, - "GetTodosResponse": { + "GetTodos200ResponseBody": { "type": "array", "items": { "type": "object", @@ -446,23 +517,18 @@ } }, "GetTodosResponseBody": { - "type": "object", - "properties": { - "id": { "type": "number" }, - "name": { "type": "string" }, - "completed": { "type": "boolean" } - }, - "required": ["id", "name", "completed"], - "additionalProperties": false - }, - "PostTodosErrorResponseBody": { "type": "string" }, - "PostTodosRequestBody": { - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"], - "additionalProperties": false + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" }, + "completed": { "type": "boolean" } + }, + "required": ["id", "name", "completed"], + "additionalProperties": false + } }, - "PostTodosResponseBody": { "type": "string" }, "UnexpectedError": { "type": "object", "properties": { "message": { "type": "string" } }, diff --git a/apps/example/src/actions.ts b/apps/example/src/actions.ts new file mode 100644 index 0000000..42e7581 --- /dev/null +++ b/apps/example/src/actions.ts @@ -0,0 +1,83 @@ +'use server'; + +import { rpcOperation } from 'next-rest-framework'; +import { MOCK_TODOS, todoSchema } from 'utils'; +import { z } from 'zod'; + +// The RPC operations can be used as server-actions and imported in the RPC route handlers. + +export const getTodos = rpcOperation({ + tags: ['RPC'] +}) + .outputs([ + { + schema: z.array(todoSchema) + } + ]) + .handler(() => { + return MOCK_TODOS; // Type-checked output. + }); + +export const getTodoById = rpcOperation({ + tags: ['RPC'] +}) + .input(z.string()) + .outputs([ + { + schema: z.object({ + error: z.string() + }) + }, + { + schema: todoSchema + } + ]) + .handler((id) => { + const todo = MOCK_TODOS.find((t) => t.id === Number(id)); + + if (!todo) { + return { error: 'TODO not found.' }; // Type-checked output. + } + + return todo; // Type-checked output. + }); + +export const createTodo = rpcOperation({ + tags: ['RPC'] +}) + .input( + z.object({ + name: z.string() + }) + ) + .outputs([{ schema: todoSchema }]) + .handler( + async ({ + name // Strictly-typed input. + }) => { + // Create todo. + const todo = { id: 2, name, completed: false }; + return todo; // Type-checked output. + } + ); + +export const deleteTodo = rpcOperation({ + tags: ['RPC'] +}) + .input(z.string()) + .outputs([ + { schema: z.object({ error: z.string() }) }, + { schema: z.object({ message: z.string() }) } + ]) + .handler((id) => { + // Delete todo. + const todo = MOCK_TODOS.find((t) => t.id === Number(id)); + + if (!todo) { + return { + error: 'TODO not found.' // Type-checked output. + }; + } + + return { message: 'TODO deleted.' }; // Type-checked output. + }); diff --git a/apps/example/src/app/api/route.ts b/apps/example/src/app/api/route.ts deleted file mode 100644 index 6b94d47..0000000 --- a/apps/example/src/app/api/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { docsRouteHandler } from 'next-rest-framework'; - -export const GET = docsRouteHandler({ - deniedPaths: ['/api/routes/third-party-endpoint'], - openApiJsonPath: '/openapi.json' -}); diff --git a/apps/example/src/app/api/routes/rpc/route.ts b/apps/example/src/app/api/routes/rpc/route.ts deleted file mode 100644 index c454492..0000000 --- a/apps/example/src/app/api/routes/rpc/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -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() - // Output schema for strictly-typed responses and OpenAPI documentation. - .outputs([ - { - schema: z.array( - z.object({ - id: z.number(), - name: z.string(), - completed: z.boolean() - }) - ) - } - ]) - .handler(() => { - // Type-checked response. - return TODOS; - }), - - getTodoById: rpcOperation() - .input(z.string()) - .outputs([ - { - 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)); - - if (!todo) { - // Type-checked response. - return { error: 'TODO not found.' }; - } - - // Type-checked response. - return todo; - }), - - 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. - .outputs([{ schema: z.object({ message: z.string() }) }]) - .handler(async ({ name }) => { - // Type-checked response. - return { message: `New TODO created: ${name}` }; - }), - - deleteTodo: rpcOperation() - .input(z.string()) - .outputs([ - { schema: z.object({ error: z.string() }) }, - { schema: 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; diff --git a/apps/example/src/app/api/v2/route.ts b/apps/example/src/app/api/v2/route.ts new file mode 100644 index 0000000..41569b7 --- /dev/null +++ b/apps/example/src/app/api/v2/route.ts @@ -0,0 +1,6 @@ +import { docsRoute } from 'next-rest-framework'; + +export const { GET } = docsRoute({ + deniedPaths: ['/api/routes/third-party-endpoint'], + openApiJsonPath: '/openapi.json' +}); diff --git a/apps/example/src/app/api/v2/rpc/[operationId]/route.ts b/apps/example/src/app/api/v2/rpc/[operationId]/route.ts new file mode 100644 index 0000000..ddd0285 --- /dev/null +++ b/apps/example/src/app/api/v2/rpc/[operationId]/route.ts @@ -0,0 +1,13 @@ +import { createTodo, deleteTodo, getTodoById, getTodos } from 'actions'; +import { rpcRoute } from 'next-rest-framework'; + +const { POST, client } = rpcRoute({ + getTodos, + getTodoById, + createTodo, + deleteTodo +}); + +export type RpcClient = typeof client; + +export { POST }; diff --git a/apps/example/src/app/api/routes/third-party-endpoint/route.ts b/apps/example/src/app/api/v2/third-party-endpoint/route.ts similarity index 100% rename from apps/example/src/app/api/routes/third-party-endpoint/route.ts rename to apps/example/src/app/api/v2/third-party-endpoint/route.ts diff --git a/apps/example/src/app/api/routes/todos/[id]/route.ts b/apps/example/src/app/api/v2/todos/[id]/route.ts similarity index 74% rename from apps/example/src/app/api/routes/todos/[id]/route.ts rename to apps/example/src/app/api/v2/todos/[id]/route.ts index 57df8a0..5bc8752 100644 --- a/apps/example/src/app/api/routes/todos/[id]/route.ts +++ b/apps/example/src/app/api/v2/todos/[id]/route.ts @@ -1,8 +1,4 @@ -import { - TypedNextResponse, - routeHandler, - routeOperation -} from 'next-rest-framework'; +import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; import { z } from 'zod'; const TODOS = [ @@ -14,10 +10,13 @@ const TODOS = [ ]; // Example dynamic App Router route handler with GET/DELETE handlers. -export const GET = routeHandler({ - GET: routeOperation({ - operationId: 'getTodoById', - tags: ['example-api', 'todos', 'app-router'] +export const { GET, DELETE } = route({ + getTodoById: routeOperation({ + method: 'GET', + // Optional OpenAPI operation documentation. + openApiOperation: { + tags: ['example-api', 'todos', 'app-router'] + } }) .outputs([ { @@ -49,9 +48,12 @@ export const GET = routeHandler({ }); }), - DELETE: routeOperation({ - operationId: 'deleteTodo', - tags: ['example-api', 'todos', 'app-router'] + deleteTodo: routeOperation({ + method: 'DELETE', + // Optional OpenAPI operation documentation. + openApiOperation: { + tags: ['example-api', 'todos', 'app-router'] + } }) .outputs([ { diff --git a/apps/example/src/app/api/routes/todos/route.ts b/apps/example/src/app/api/v2/todos/route.ts similarity index 56% rename from apps/example/src/app/api/routes/todos/route.ts rename to apps/example/src/app/api/v2/todos/route.ts index b2935a1..d91c7fe 100644 --- a/apps/example/src/app/api/routes/todos/route.ts +++ b/apps/example/src/app/api/v2/todos/route.ts @@ -1,50 +1,37 @@ -import { - TypedNextResponse, - routeHandler, - routeOperation -} from 'next-rest-framework'; +import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; +import { MOCK_TODOS, todoSchema } from 'utils'; import { z } from 'zod'; -const TODOS = [ - { - id: 1, - name: 'TODO 1', - completed: false - } -]; - // Example App Router route handler with GET/POST handlers. -const handler = routeHandler({ - GET: routeOperation({ +const { GET, POST } = route({ + getTodos: routeOperation({ + method: 'GET', // Optional OpenAPI operation documentation. - operationId: 'getTodos', - tags: ['example-api', 'todos', 'app-router'] + openApiOperation: { + tags: ['example-api', 'todos', 'app-router'] + } }) // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 200, contentType: 'application/json', - schema: z.array( - z.object({ - id: z.number(), - name: z.string(), - completed: z.boolean() - }) - ) + schema: z.array(todoSchema) } ]) .handler(() => { // Type-checked response. - return TypedNextResponse.json(TODOS, { + return TypedNextResponse.json(MOCK_TODOS, { status: 200 }); }), - POST: routeOperation({ + createTodo: routeOperation({ + method: 'POST', // Optional OpenAPI operation documentation. - operationId: 'createTodo', - tags: ['example-api', 'todos', 'app-router'] + openApiOperation: { + tags: ['example-api', 'todos', 'app-router'] + } }) // Input schema for strictly-typed request, request validation and OpenAPI documentation. .input({ @@ -66,15 +53,17 @@ const handler = routeHandler({ schema: z.string() } ]) - // Optional middleware logic executed before request validation. - .middleware((req) => { - if (!req.headers.get('authorization')) { - // Type-checked response. - return TypedNextResponse.json('Unauthorized', { - status: 401 - }); + .middleware( + // Optional middleware logic executed before request validation. + (req) => { + if (!req.headers.get('authorization')) { + // Type-checked response. + return TypedNextResponse.json('Unauthorized', { + status: 401 + }); + } } - }) + ) .handler(async (req) => { const { name } = await req.json(); // Strictly-typed request. @@ -85,4 +74,4 @@ const handler = routeHandler({ }) }); -export { handler as GET, handler as POST }; +export { GET, POST }; diff --git a/apps/example/src/app/client/ClientExample.tsx b/apps/example/src/app/client/ClientExample.tsx new file mode 100644 index 0000000..8683c56 --- /dev/null +++ b/apps/example/src/app/client/ClientExample.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { type RpcClient } from 'app/api/v2/rpc/[operationId]/route'; +import { rpcClient } from 'next-rest-framework/dist/client/rpc-client'; +import { useEffect, useState } from 'react'; +import { type Todo } from 'utils'; + +const client = rpcClient({ + url: 'http://localhost:3000/api/v2/rpc' +}); + +export const ClientExample: React.FC = () => { + const [loading, setLoading] = useState(true); + const [todos, setTodos] = useState([]); + + useEffect(() => { + client + .getTodos() + .then(setTodos) + .catch(console.error) + .finally(() => { + setLoading(false); + }); + }, []); + + return ( +
+

RPC client example

+ {loading ? ( +

Loading...

+ ) : ( + <> +

Data:

{JSON.stringify(todos)}

+ + )} +
+ ); +}; diff --git a/apps/example/src/app/client/page.tsx b/apps/example/src/app/client/page.tsx new file mode 100644 index 0000000..3347e14 --- /dev/null +++ b/apps/example/src/app/client/page.tsx @@ -0,0 +1,23 @@ +import { getTodos } from 'actions'; +import { Footer } from '../components/Footer'; +import { Navbar } from '../components/Navbar'; +import { ClientExample } from './ClientExample'; + +export default async function Page() { + const todos = await getTodos(); + + return ( + <> + +
+
+

RPC server-side client example

+

Data:

+

{JSON.stringify(todos)}

+
+ +
+