From 8fb181671188c08d688628557a08b290bf91a7a4 Mon Sep 17 00:00:00 2001 From: Markus Blomqvist Date: Tue, 2 Apr 2024 21:16:12 +0300 Subject: [PATCH] Improve path parameter validation WIP --- apps/example/public/openapi.json | 238 ++++++++++++------ apps/example/src/actions.ts | 58 +++-- .../app/api/v2/form-data/multipart/route.ts | 4 +- .../app/api/v2/form-data/url-encoded/route.ts | 4 +- .../[slug]}/route.ts | 14 +- .../v2/route-with-path-params/[slug]/route.ts | 30 --- .../src/app/api/v2/todos/[id]/route.ts | 20 +- apps/example/src/app/api/v2/todos/route.ts | 16 +- .../pages/api/v1/form-data/multipart/index.ts | 4 +- .../api/v1/form-data/url-encoded/index.ts | 4 +- .../[slug]/index.ts | 11 +- .../api/v1/route-with-query-params/index.ts | 26 -- apps/example/src/pages/api/v1/todos/[id].ts | 18 +- apps/example/src/pages/api/v1/todos/index.ts | 14 +- .../src/app-router/route-operation.ts | 2 + .../src/app-router/route.ts | 8 - .../src/pages-router/api-route-operation.ts | 42 +++- .../src/pages-router/api-route.ts | 52 +++- .../next-rest-framework/src/shared/paths.ts | 100 ++++++-- .../next-rest-framework/src/shared/schemas.ts | 5 +- .../tests/app-router/route.test.ts | 82 ++++++ .../tests/pages-router/api-route.test.ts | 81 ++++++ packages/next-rest-framework/tests/utils.ts | 28 ++- 23 files changed, 617 insertions(+), 244 deletions(-) rename apps/example/src/app/api/v2/{route-with-query-params => route-with-params/[slug]}/route.ts (59%) delete mode 100644 apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts rename apps/example/src/pages/api/v1/{route-with-path-params => route-with-params}/[slug]/index.ts (60%) delete mode 100644 apps/example/src/pages/api/v1/route-with-query-params/index.ts diff --git a/apps/example/public/openapi.json b/apps/example/public/openapi.json index 9ae70ef..bf565db 100644 --- a/apps/example/public/openapi.json +++ b/apps/example/public/openapi.json @@ -16,11 +16,12 @@ "$ref": "#/components/schemas/MultipartFormDataRequestBody" } } - } + }, + "description": "Test form description." }, "responses": { "200": { - "description": "Response for status 200", + "description": "File response.", "content": { "application/octet-stream": { "schema": { @@ -50,11 +51,12 @@ "$ref": "#/components/schemas/UrlEncodedFormDataRequestBody" } } - } + }, + "description": "Test form description." }, "responses": { "200": { - "description": "Response for status 200", + "description": "Test form response.", "content": { "application/json": { "schema": { @@ -74,16 +76,16 @@ } } }, - "/api/v1/route-with-query-params": { + "/api/v1/route-with-path-params/{slug}": { "get": { - "operationId": "getQueryParams", + "operationId": "getPathParams", "responses": { "200": { - "description": "Response for status 200", + "description": "Path parameters response.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetQueryParams200ResponseBody" + "$ref": "#/components/schemas/GetPathParams200ResponseBody" } } } @@ -99,19 +101,40 @@ }, "parameters": [ { - "name": "foo", - "in": "query", + "name": "slug", + "in": "path", "required": true, - "schema": { "type": "string", "format": "uuid" } - }, - { - "name": "bar", - "in": "query", - "required": false, - "schema": { "type": "string" } + "schema": { "type": "string", "enum": ["foo", "bar", "baz"] } + } + ] + } + }, + "/api/v1/route-with-query-params": { + "get": { + "operationId": "getQueryParams", + "responses": { + "200": { + "description": "Query parameters response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetQueryParams200ResponseBody" + } + } + } }, + "500": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + }, + "parameters": [ { - "name": "baz", + "name": "total", "in": "query", "required": true, "schema": { "type": "string" } @@ -315,7 +338,7 @@ "operationId": "getTodos", "responses": { "200": { - "description": "Response for status 200", + "description": "List of TODOs.", "content": { "application/json": { "schema": { @@ -341,11 +364,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/CreateTodoRequestBody" } } - } + }, + "description": "New TODO's name." }, "responses": { "201": { - "description": "Response for status 201", + "description": "New TODO created message.", "content": { "application/json": { "schema": { @@ -355,7 +379,7 @@ } }, "401": { - "description": "Response for status 401", + "description": "Unauthorized.", "content": { "application/json": { "schema": { @@ -380,7 +404,7 @@ "operationId": "getTodoById", "responses": { "200": { - "description": "Response for status 200", + "description": "TODO response.", "content": { "application/json": { "schema": { @@ -390,7 +414,7 @@ } }, "404": { - "description": "Response for status 404", + "description": "TODO not found.", "content": { "application/json": { "schema": { @@ -469,11 +493,12 @@ "$ref": "#/components/schemas/MultipartFormDataRequestBody" } } - } + }, + "description": "Test form description." }, "responses": { "200": { - "description": "Response for status 200", + "description": "File response.", "content": { "application/octet-stream": { "schema": { @@ -503,11 +528,12 @@ "$ref": "#/components/schemas/UrlEncodedFormDataRequestBody" } } - } + }, + "description": "Test form description." }, "responses": { "200": { - "description": "Response for status 200", + "description": "Test form response.", "content": { "application/octet-stream": { "schema": { @@ -527,16 +553,16 @@ } } }, - "/api/v2/route-with-query-params": { + "/api/v2/route-with-path-params/{slug}": { "get": { - "operationId": "getQueryParams", + "operationId": "getPathParams", "responses": { "200": { - "description": "Response for status 200", + "description": "Path parameters response.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetQueryParams200ResponseBody" + "$ref": "#/components/schemas/GetPathParams200ResponseBody" } } } @@ -552,19 +578,40 @@ }, "parameters": [ { - "name": "foo", - "in": "query", + "name": "slug", + "in": "path", "required": true, - "schema": { "type": "string", "format": "uuid" } - }, - { - "name": "bar", - "in": "query", - "required": false, - "schema": { "type": "string" } + "schema": { "type": "string", "enum": ["foo", "bar", "baz"] } + } + ] + } + }, + "/api/v2/route-with-query-params": { + "get": { + "operationId": "getQueryParams", + "responses": { + "200": { + "description": "Query parameters response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetQueryParams200ResponseBody" + } + } + } }, + "500": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + }, + "parameters": [ { - "name": "baz", + "name": "total", "in": "query", "required": true, "schema": { "type": "string" } @@ -768,7 +815,7 @@ "operationId": "getTodos", "responses": { "200": { - "description": "Response for status 200", + "description": "List of TODOs.", "content": { "application/json": { "schema": { @@ -794,11 +841,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/CreateTodoRequestBody" } } - } + }, + "description": "New TODO's name." }, "responses": { "201": { - "description": "Response for status 201", + "description": "New TODO created message.", "content": { "application/json": { "schema": { @@ -808,7 +856,7 @@ } }, "401": { - "description": "Response for status 401", + "description": "Unauthorized.", "content": { "application/json": { "schema": { @@ -833,7 +881,7 @@ "operationId": "getTodoById", "responses": { "200": { - "description": "Response for status 200", + "description": "TODO response.", "content": { "application/json": { "schema": { @@ -843,7 +891,7 @@ } }, "404": { - "description": "Response for status 404", + "description": "TODO not found.", "content": { "application/json": { "schema": { @@ -874,7 +922,7 @@ "operationId": "deleteTodo", "responses": { "204": { - "description": "Response for status 204", + "description": "TODO deleted.", "content": { "application/json": { "schema": { @@ -884,7 +932,7 @@ } }, "404": { - "description": "Response for status 404", + "description": "TODO not found.", "content": { "application/json": { "schema": { @@ -915,13 +963,20 @@ }, "components": { "schemas": { - "CreateTodo201ResponseBody": { "type": "string" }, - "CreateTodo401ResponseBody": { "type": "string" }, + "CreateTodo201ResponseBody": { + "type": "string", + "description": "New TODO created message." + }, + "CreateTodo401ResponseBody": { + "type": "string", + "description": "Unauthorized." + }, "CreateTodoRequestBody": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"], - "additionalProperties": false + "additionalProperties": false, + "description": "New TODO's name." }, "CreateTodoResponseBody": { "type": "object", @@ -940,42 +995,58 @@ "type": "object", "properties": { "error": { "type": "string" } }, "required": ["error"], - "additionalProperties": false + "additionalProperties": false, + "description": "TODO not found." }, "DeleteTodoResponseBody2": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"], - "additionalProperties": false + "additionalProperties": false, + "description": "TODO deleted message." }, "FormDataMultipartRequestBody": { + "description": "Test form description.", "type": "object", - "properties": { "text": { "type": "string" }, "file": {} }, - "required": ["text", "file"], - "additionalProperties": false + "properties": { + "text": { "type": "string" }, + "file": { "type": "string", "format": "binary" } + } + }, + "FormDataMultipartResponseBody": { + "description": "File response.", + "type": "string", + "format": "binary" }, - "FormDataMultipartResponseBody": { "type": "string", "format": "binary" }, "FormDataUrlEncodedRequestBody": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"], - "additionalProperties": false + "additionalProperties": false, + "description": "Test form description." }, "FormDataUrlEncodedResponseBody": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"], - "additionalProperties": false + "additionalProperties": false, + "description": "Test form response." }, - "GetQueryParams200ResponseBody": { + "GetPathParams200ResponseBody": { "type": "object", "properties": { - "foo": { "type": "string", "format": "uuid" }, - "bar": { "type": "string" }, - "baz": { "type": "string" } + "slug": { "type": "string", "enum": ["foo", "bar", "baz"] } }, - "required": ["foo", "baz"], - "additionalProperties": false + "required": ["slug"], + "additionalProperties": false, + "description": "Path parameters response." + }, + "GetQueryParams200ResponseBody": { + "type": "object", + "properties": { "total": { "type": "string" } }, + "required": ["total"], + "additionalProperties": false, + "description": "Query parameters response." }, "GetTodoById200ResponseBody": { "type": "object", @@ -985,15 +1056,23 @@ "completed": { "type": "boolean" } }, "required": ["id", "name", "completed"], - "additionalProperties": false + "additionalProperties": false, + "description": "TODO response." + }, + "GetTodoById404ResponseBody": { + "type": "string", + "description": "TODO not found." + }, + "GetTodoByIdRequestBody": { + "type": "string", + "description": "TODO name." }, - "GetTodoById404ResponseBody": { "type": "string" }, - "GetTodoByIdRequestBody": { "type": "string" }, "GetTodoByIdResponseBody": { "type": "object", "properties": { "error": { "type": "string" } }, "required": ["error"], - "additionalProperties": false + "additionalProperties": false, + "description": "TODO not found." }, "GetTodoByIdResponseBody2": { "type": "object", @@ -1003,7 +1082,8 @@ "completed": { "type": "boolean" } }, "required": ["id", "name", "completed"], - "additionalProperties": false + "additionalProperties": false, + "description": "TODO response." }, "GetTodos200ResponseBody": { "type": "array", @@ -1016,7 +1096,8 @@ }, "required": ["id", "name", "completed"], "additionalProperties": false - } + }, + "description": "List of TODOs." }, "GetTodosResponseBody": { "type": "array", @@ -1029,13 +1110,16 @@ }, "required": ["id", "name", "completed"], "additionalProperties": false - } + }, + "description": "List of TODOs." }, "MultipartFormData200ResponseBody": { + "description": "File response.", "type": "string", "format": "binary" }, "MultipartFormDataRequestBody": { + "description": "Test form description.", "type": "object", "properties": { "text": { "type": "string" }, @@ -1051,13 +1135,15 @@ "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"], - "additionalProperties": false + "additionalProperties": false, + "description": "Test form response." }, "UrlEncodedFormDataRequestBody": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"], - "additionalProperties": false + "additionalProperties": false, + "description": "Test form description." } } } diff --git a/apps/example/src/actions.ts b/apps/example/src/actions.ts index c0428d4..9b8b285 100644 --- a/apps/example/src/actions.ts +++ b/apps/example/src/actions.ts @@ -14,7 +14,7 @@ import { z } from 'zod'; export const getTodos = rpcOperation() .outputs([ { - body: z.array(todoSchema), + body: z.array(todoSchema).describe('List of TODOs.'), contentType: 'application/json' } ]) @@ -25,17 +25,19 @@ export const getTodos = rpcOperation() export const getTodoById = rpcOperation() .input({ contentType: 'application/json', - body: z.string() + body: z.string().describe('TODO name.') }) .outputs([ { - body: z.object({ - error: z.string() - }), + body: z + .object({ + error: z.string() + }) + .describe('TODO not found.'), contentType: 'application/json' }, { - body: todoSchema, + body: todoSchema.describe('TODO response.'), contentType: 'application/json' } ]) @@ -52,9 +54,11 @@ export const getTodoById = rpcOperation() export const createTodo = rpcOperation() .input({ contentType: 'application/json', - body: z.object({ - name: z.string() - }) + body: z + .object({ + name: z.string() + }) + .describe("New TODO's name.") }) .outputs([{ body: todoSchema, contentType: 'application/json' }]) .handler(async ({ name }) => { @@ -68,8 +72,14 @@ export const deleteTodo = rpcOperation() body: z.string() }) .outputs([ - { body: z.object({ error: z.string() }), contentType: 'application/json' }, - { body: z.object({ message: z.string() }), contentType: 'application/json' } + { + body: z.object({ error: z.string() }).describe('TODO not found.'), + contentType: 'application/json' + }, + { + body: z.object({ message: z.string() }).describe('TODO deleted message.'), + contentType: 'application/json' + } ]) .handler((id) => { const todo = MOCK_TODOS.find((t) => t.id === Number(id)); @@ -86,9 +96,14 @@ export const deleteTodo = rpcOperation() export const formDataUrlEncoded = rpcOperation() .input({ contentType: 'application/x-www-form-urlencoded', - body: formSchema // A zod-form-data schema is required. + body: formSchema.describe('Test form description.') // A zod-form-data schema is required. }) - .outputs([{ body: formSchema, contentType: 'application/json' }]) + .outputs([ + { + body: formSchema.describe('Test form response.'), + contentType: 'application/json' + } + ]) .handler((formData) => { return { text: formData.get('text') @@ -98,13 +113,28 @@ export const formDataUrlEncoded = rpcOperation() export const formDataMultipart = rpcOperation() .input({ contentType: 'multipart/form-data', - body: multipartFormSchema // A zod-form-data schema is required. + body: multipartFormSchema, // A zod-form-data schema is required. + // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. + bodySchema: { + description: 'Test form description.', + type: 'object', + properties: { + text: { + type: 'string' + }, + file: { + type: 'string', + format: 'binary' + } + } + } }) .outputs([ { body: z.custom(), // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. bodySchema: { + description: 'File response.', type: 'string', format: 'binary' }, diff --git a/apps/example/src/app/api/v2/form-data/multipart/route.ts b/apps/example/src/app/api/v2/form-data/multipart/route.ts index 2f594b8..95dd38b 100644 --- a/apps/example/src/app/api/v2/form-data/multipart/route.ts +++ b/apps/example/src/app/api/v2/form-data/multipart/route.ts @@ -13,6 +13,7 @@ export const { POST } = route({ body: multipartFormSchema, // A zod-form-data schema is required. // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. bodySchema: { + description: 'Test form description.', type: 'object', properties: { text: { @@ -29,9 +30,10 @@ export const { POST } = route({ { status: 200, contentType: 'application/octet-stream', - body: z.unknown(), + body: z.custom(), // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. bodySchema: { + description: 'File response.', type: 'string', format: 'binary' } diff --git a/apps/example/src/app/api/v2/form-data/url-encoded/route.ts b/apps/example/src/app/api/v2/form-data/url-encoded/route.ts index d72d834..5828bd5 100644 --- a/apps/example/src/app/api/v2/form-data/url-encoded/route.ts +++ b/apps/example/src/app/api/v2/form-data/url-encoded/route.ts @@ -9,13 +9,13 @@ export const { POST } = route({ }) .input({ contentType: 'application/x-www-form-urlencoded', - body: formSchema // A zod-form-data schema is required. + body: formSchema.describe('Test form description.') // A zod-form-data schema is required. }) .outputs([ { status: 200, contentType: 'application/octet-stream', - body: formSchema + body: formSchema.describe('Test form response.') // A zod-form-data schema is required. } ]) .handler(async (req) => { diff --git a/apps/example/src/app/api/v2/route-with-query-params/route.ts b/apps/example/src/app/api/v2/route-with-params/[slug]/route.ts similarity index 59% rename from apps/example/src/app/api/v2/route-with-query-params/route.ts rename to apps/example/src/app/api/v2/route-with-params/[slug]/route.ts index 084d326..68bfd13 100644 --- a/apps/example/src/app/api/v2/route-with-query-params/route.ts +++ b/apps/example/src/app/api/v2/route-with-params/[slug]/route.ts @@ -1,6 +1,10 @@ import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; import { z } from 'zod'; +const paramsSchema = z.object({ + slug: z.enum(['foo', 'bar', 'baz']) +}); + const querySchema = z.object({ total: z.string() }); @@ -8,24 +12,26 @@ const querySchema = z.object({ export const runtime = 'edge'; export const { GET } = route({ - getQueryParams: routeOperation({ + getPathParams: routeOperation({ method: 'GET' }) .input({ contentType: 'application/json', - query: querySchema + params: paramsSchema.describe('Path parameters input.'), + query: querySchema.describe('Query parameters input.') }) .outputs([ { status: 200, contentType: 'application/json', - body: querySchema + body: paramsSchema.merge(querySchema).describe('Parameters response.') } ]) - .handler((req) => { + .handler((req, { params: { slug } }) => { const query = req.nextUrl.searchParams; return TypedNextResponse.json({ + slug, total: query.get('total') ?? '' }); }) diff --git a/apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts b/apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts deleted file mode 100644 index faa748d..0000000 --- a/apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; -import { z } from 'zod'; - -const paramsSchema = z.object({ - slug: z.enum(['foo', 'bar', 'baz']) -}); - -export const runtime = 'edge'; - -export const { GET } = route({ - getPathParams: routeOperation({ - method: 'GET' - }) - .input({ - contentType: 'application/json', - params: paramsSchema - }) - .outputs([ - { - status: 200, - contentType: 'application/json', - body: paramsSchema - } - ]) - .handler((_req, { params: { slug } }) => { - return TypedNextResponse.json({ - slug - }); - }) -}); diff --git a/apps/example/src/app/api/v2/todos/[id]/route.ts b/apps/example/src/app/api/v2/todos/[id]/route.ts index 5eecbda..3a48d6c 100644 --- a/apps/example/src/app/api/v2/todos/[id]/route.ts +++ b/apps/example/src/app/api/v2/todos/[id]/route.ts @@ -4,18 +4,27 @@ import { z } from 'zod'; export const runtime = 'edge'; +const paramsSchema = z + .object({ + id: z.string() + }) + .describe('TODO ID path parameter.'); + export const { GET, DELETE } = route({ getTodoById: routeOperation({ method: 'GET' }) + .input({ + params: paramsSchema + }) .outputs([ { - body: todoSchema, + body: todoSchema.describe('TODO response.'), status: 200, contentType: 'application/json' }, { - body: z.string(), + body: z.string().describe('TODO not found.'), status: 404, contentType: 'application/json' } @@ -37,14 +46,17 @@ export const { GET, DELETE } = route({ deleteTodo: routeOperation({ method: 'DELETE' }) + .input({ + params: paramsSchema + }) .outputs([ { - body: z.string(), + body: z.string().describe('TODO deleted.'), status: 204, contentType: 'application/json' }, { - body: z.string(), + body: z.string().describe('TODO not found.'), status: 404, contentType: 'application/json' } diff --git a/apps/example/src/app/api/v2/todos/route.ts b/apps/example/src/app/api/v2/todos/route.ts index af51993..afbe95e 100644 --- a/apps/example/src/app/api/v2/todos/route.ts +++ b/apps/example/src/app/api/v2/todos/route.ts @@ -12,7 +12,7 @@ export const { GET, POST } = route({ { status: 200, contentType: 'application/json', - body: z.array(todoSchema) + body: z.array(todoSchema).describe('List of TODOs.') } ]) .handler(() => { @@ -26,26 +26,28 @@ export const { GET, POST } = route({ }) .input({ contentType: 'application/json', - body: z.object({ - name: z.string() - }) + body: z + .object({ + name: z.string() + }) + .describe("New TODO's name.") }) .outputs([ { status: 201, contentType: 'application/json', - body: z.string() + body: z.string().describe('New TODO created message.') }, { status: 401, contentType: 'application/json', - body: z.string() + body: z.string().describe('Unauthorized.') } ]) // Optional middleware logic executed before request validation. .middleware((req) => { if (!req.headers.get('very-secure')) { - return TypedNextResponse.json('Unauthorized', { + return TypedNextResponse.json('Unauthorized.', { status: 401 }); } diff --git a/apps/example/src/pages/api/v1/form-data/multipart/index.ts b/apps/example/src/pages/api/v1/form-data/multipart/index.ts index be261da..6b95ac4 100644 --- a/apps/example/src/pages/api/v1/form-data/multipart/index.ts +++ b/apps/example/src/pages/api/v1/form-data/multipart/index.ts @@ -18,6 +18,7 @@ export default apiRoute({ body: multipartFormSchema, // A zod-form-data schema is required. // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. bodySchema: { + description: 'Test form description.', type: 'object', properties: { text: { @@ -34,9 +35,10 @@ export default apiRoute({ { status: 200, contentType: 'application/octet-stream', - body: z.unknown(), + body: z.custom(), // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. bodySchema: { + description: 'File response.', type: 'string', format: 'binary' } diff --git a/apps/example/src/pages/api/v1/form-data/url-encoded/index.ts b/apps/example/src/pages/api/v1/form-data/url-encoded/index.ts index 235aaef..479f9f3 100644 --- a/apps/example/src/pages/api/v1/form-data/url-encoded/index.ts +++ b/apps/example/src/pages/api/v1/form-data/url-encoded/index.ts @@ -7,13 +7,13 @@ export default apiRoute({ }) .input({ contentType: 'application/x-www-form-urlencoded', - body: formSchema // A zod-form-data schema is required. + body: formSchema.describe('Test form description.') // A zod-form-data schema is required. }) .outputs([ { status: 200, contentType: 'application/json', - body: formSchema + body: formSchema.describe('Test form response.') } ]) .handler((req, res) => { diff --git a/apps/example/src/pages/api/v1/route-with-path-params/[slug]/index.ts b/apps/example/src/pages/api/v1/route-with-params/[slug]/index.ts similarity index 60% rename from apps/example/src/pages/api/v1/route-with-path-params/[slug]/index.ts rename to apps/example/src/pages/api/v1/route-with-params/[slug]/index.ts index 72a483e..64c1291 100644 --- a/apps/example/src/pages/api/v1/route-with-path-params/[slug]/index.ts +++ b/apps/example/src/pages/api/v1/route-with-params/[slug]/index.ts @@ -5,19 +5,24 @@ const paramsSchema = z.object({ slug: z.enum(['foo', 'bar', 'baz']) }); +const querySchema = z.object({ + total: z.string() +}); + export default apiRoute({ - getQueryParams: apiRouteOperation({ + getParams: apiRouteOperation({ method: 'GET' }) .input({ contentType: 'application/json', - query: paramsSchema + params: paramsSchema.describe('Path parameters input.'), + query: querySchema.describe('Query parameters input.') }) .outputs([ { status: 200, contentType: 'application/json', - body: paramsSchema + body: paramsSchema.merge(querySchema).describe('Parameters response.') } ]) .handler((req, res) => { diff --git a/apps/example/src/pages/api/v1/route-with-query-params/index.ts b/apps/example/src/pages/api/v1/route-with-query-params/index.ts deleted file mode 100644 index ed278e5..0000000 --- a/apps/example/src/pages/api/v1/route-with-query-params/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { apiRoute, apiRouteOperation } from 'next-rest-framework'; -import { z } from 'zod'; - -const querySchema = z.object({ - total: z.string() -}); - -export default apiRoute({ - getQueryParams: apiRouteOperation({ - method: 'GET' - }) - .input({ - contentType: 'application/json', - query: querySchema - }) - .outputs([ - { - status: 200, - contentType: 'application/json', - body: querySchema - } - ]) - .handler((req, res) => { - res.json(req.query); - }) -}); diff --git a/apps/example/src/pages/api/v1/todos/[id].ts b/apps/example/src/pages/api/v1/todos/[id].ts index ac53781..3e32cd2 100644 --- a/apps/example/src/pages/api/v1/todos/[id].ts +++ b/apps/example/src/pages/api/v1/todos/[id].ts @@ -2,23 +2,27 @@ import { MOCK_TODOS, todoSchema } from '@/utils'; import { apiRoute, apiRouteOperation } from 'next-rest-framework'; import { z } from 'zod'; +const paramsSchema = z + .object({ + id: z.string() + }) + .describe('TODO ID path parameter.'); + export default apiRoute({ getTodoById: apiRouteOperation({ method: 'GET' }) .input({ - query: z.object({ - id: z.string() - }) + params: paramsSchema }) .outputs([ { - body: todoSchema, + body: todoSchema.describe('TODO response.'), status: 200, contentType: 'application/json' }, { - body: z.string(), + body: z.string().describe('TODO not found.'), status: 404, contentType: 'application/json' } @@ -38,9 +42,7 @@ export default apiRoute({ method: 'DELETE' }) .input({ - query: z.object({ - id: z.string() - }) + params: paramsSchema }) .outputs([ { diff --git a/apps/example/src/pages/api/v1/todos/index.ts b/apps/example/src/pages/api/v1/todos/index.ts index 13f51f8..4a48bb8 100644 --- a/apps/example/src/pages/api/v1/todos/index.ts +++ b/apps/example/src/pages/api/v1/todos/index.ts @@ -10,7 +10,7 @@ export default apiRoute({ { status: 200, contentType: 'application/json', - body: z.array(todoSchema) + body: z.array(todoSchema).describe('List of TODOs.') } ]) .handler((_req, res) => { @@ -22,20 +22,22 @@ export default apiRoute({ }) .input({ contentType: 'application/json', - body: z.object({ - name: z.string() - }) + body: z + .object({ + name: z.string() + }) + .describe("New TODO's name.") }) .outputs([ { status: 201, contentType: 'application/json', - body: z.string() + body: z.string().describe('New TODO created message.') }, { status: 401, contentType: 'application/json', - body: z.string() + body: z.string().describe('Unauthorized.') } ]) // Optional middleware logic executed before request validation. diff --git a/packages/next-rest-framework/src/app-router/route-operation.ts b/packages/next-rest-framework/src/app-router/route-operation.ts index f0dbcaa..923b768 100644 --- a/packages/next-rest-framework/src/app-router/route-operation.ts +++ b/packages/next-rest-framework/src/app-router/route-operation.ts @@ -217,6 +217,8 @@ interface InputObject< /*! If defined, this will override the query schema for the OpenAPI spec. */ querySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; params?: ZodSchema; + /*! If defined, this will override the params schema for the OpenAPI spec. */ + paramsSchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; } export interface RouteOperationDefinition< diff --git a/packages/next-rest-framework/src/app-router/route.ts b/packages/next-rest-framework/src/app-router/route.ts index 77339fc..14f21fa 100644 --- a/packages/next-rest-framework/src/app-router/route.ts +++ b/packages/next-rest-framework/src/app-router/route.ts @@ -269,14 +269,6 @@ export const route = >( } context.params = data; - const url = new URL(reqClone.url); - url.search = new URLSearchParams(context.params).toString(); - - reqClone = new NextRequest(url, { - method: reqClone.method, - headers: reqClone.headers, - body: reqClone.body - }); } } diff --git a/packages/next-rest-framework/src/pages-router/api-route-operation.ts b/packages/next-rest-framework/src/pages-router/api-route-operation.ts index b36e9ff..2e6bff3 100644 --- a/packages/next-rest-framework/src/pages-router/api-route-operation.ts +++ b/packages/next-rest-framework/src/pages-router/api-route-operation.ts @@ -14,7 +14,8 @@ import { type TypedFormData, type ZodFormSchema, type ContentTypesThatSupportInputValidation, - type FormDataContentType + type FormDataContentType, + type BaseParams } from '../types'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; import { type ZodSchema, type z } from 'zod'; @@ -23,7 +24,7 @@ export type TypedNextApiRequest< Method = keyof typeof ValidMethod, ContentType = BaseContentType, Body = unknown, - Query = BaseQuery + QueryAndParams = BaseQuery & BaseParams > = Modify< NextApiRequest, { @@ -38,7 +39,7 @@ export type TypedNextApiRequest< : ContentType extends FormDataContentType ? TypedFormData : never; - query: Query; + query: QueryAndParams; method: ValidMethod; } >; @@ -83,6 +84,7 @@ type TypedApiRouteHandler< ContentType extends BaseContentType = BaseContentType, Body = unknown, Query extends BaseQuery = BaseQuery, + Params extends BaseParams = BaseParams, Options extends BaseOptions = BaseOptions, ResponseBody = unknown, Status extends BaseStatus = BaseStatus, @@ -91,7 +93,7 @@ type TypedApiRouteHandler< OutputObject > = ReadonlyArray> > = ( - req: TypedNextApiRequest, + req: TypedNextApiRequest, res: TypedNextApiResponse< z.infer, Outputs[number]['status'], @@ -122,7 +124,8 @@ type ApiRouteMiddleware< interface InputObject< ContentType = BaseContentType, Body = unknown, - Query = BaseQuery + Query = BaseQuery, + Params = BaseParams > { contentType?: ContentType; /*! @@ -139,6 +142,9 @@ interface InputObject< query?: ZodSchema; /*! If defined, this will override the query schema for the OpenAPI spec. */ querySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; + params?: ZodSchema; + /*! If defined, this will override the params schema for the OpenAPI spec. */ + paramsSchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; } export interface ApiRouteOperationDefinition< @@ -188,8 +194,13 @@ export const apiRouteOperation = ({ }); return { - input: ( - input: InputObject + input: < + ContentType extends BaseContentType, + Body, + Query extends BaseQuery, + Params extends BaseParams + >( + input: InputObject ) => ({ outputs: < ResponseBody, @@ -237,6 +248,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, Options3, ResponseBody, Status, @@ -259,6 +271,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, Options2, ResponseBody, Status, @@ -280,6 +293,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, Options1, ResponseBody, Status, @@ -294,6 +308,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, BaseOptions, ResponseBody, Status, @@ -327,6 +342,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, Options3, ResponseBody, Status, @@ -349,6 +365,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, Options3 > ) => @@ -376,6 +393,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, Options2, ResponseBody, Status, @@ -397,6 +415,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, Options2 > ) => createOperation({ input, middleware1, middleware2, handler }) @@ -417,6 +436,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, Options1, ResponseBody, Status, @@ -431,6 +451,7 @@ export const apiRouteOperation = ({ ContentType, Body, Query, + Params, Options1 > ) => createOperation({ input, middleware1, handler }) @@ -464,6 +485,7 @@ export const apiRouteOperation = ({ BaseContentType, unknown, BaseQuery, + BaseParams, Options3, ResponseBody, Status, @@ -485,6 +507,7 @@ export const apiRouteOperation = ({ BaseContentType, unknown, BaseQuery, + BaseParams, Options2, ResponseBody, Status, @@ -499,6 +522,7 @@ export const apiRouteOperation = ({ BaseContentType, unknown, BaseQuery, + BaseParams, Options1, ResponseBody, Status, @@ -513,6 +537,7 @@ export const apiRouteOperation = ({ BaseContentType, unknown, BaseQuery, + BaseParams, BaseOptions, ResponseBody, Status, @@ -536,6 +561,7 @@ export const apiRouteOperation = ({ BaseContentType, unknown, BaseQuery, + BaseParams, Options3 > ) => @@ -547,6 +573,7 @@ export const apiRouteOperation = ({ BaseContentType, unknown, BaseQuery, + BaseParams, Options2 > ) => createOperation({ middleware1, middleware2, handler }) @@ -557,6 +584,7 @@ export const apiRouteOperation = ({ BaseContentType, unknown, BaseQuery, + BaseParams, Options1 > ) => createOperation({ middleware1, handler }) diff --git a/packages/next-rest-framework/src/pages-router/api-route.ts b/packages/next-rest-framework/src/pages-router/api-route.ts index 7903783..695c934 100644 --- a/packages/next-rest-framework/src/pages-router/api-route.ts +++ b/packages/next-rest-framework/src/pages-router/api-route.ts @@ -99,6 +99,7 @@ export const apiRoute = >( const { body: bodySchema, query: querySchema, + params: paramsSchema, contentType: contentTypeSchema } = input; @@ -189,22 +190,51 @@ export const apiRoute = >( } } - if (querySchema) { - const { valid, errors, data } = validateSchema({ - schema: querySchema, - obj: req.query - }); + if (querySchema ?? paramsSchema) { + const requestMeta: + | { initQuery: unknown; match: { params: unknown } } + | undefined = + req[Symbol.for('NextInternalRequestMeta') as keyof NextApiRequest]; + + let parsedQuery = {}; + + if (querySchema) { + const { valid, errors, data } = validateSchema({ + schema: querySchema, + obj: requestMeta?.initQuery + }); + + if (!valid) { + res.status(400).json({ + message: DEFAULT_ERRORS.invalidQueryParameters, + errors + }); + + return; + } + + parsedQuery = { ...parsedQuery, ...data }; + } - if (!valid) { - res.status(400).json({ - message: DEFAULT_ERRORS.invalidQueryParameters, - errors + if (paramsSchema) { + const { valid, errors, data } = validateSchema({ + schema: paramsSchema, + obj: requestMeta?.match.params }); - return; + if (!valid) { + res.status(400).json({ + message: DEFAULT_ERRORS.invalidPathParameters, + errors + }); + + return; + } + + parsedQuery = { ...parsedQuery, ...data }; } - req.query = data; + req.query = parsedQuery; } } diff --git a/packages/next-rest-framework/src/shared/paths.ts b/packages/next-rest-framework/src/shared/paths.ts index cf7b704..5b10fac 100644 --- a/packages/next-rest-framework/src/shared/paths.ts +++ b/packages/next-rest-framework/src/shared/paths.ts @@ -95,6 +95,13 @@ export const getPathsFromRoute = ({ } } }; + + const description = + input.bodySchema?.description ?? input.body._def.description; + + if (description) { + generatedOperationObject.requestBody.description = description; + } } const usedStatusCodes: number[] = []; @@ -136,9 +143,14 @@ export const getPathsFromRoute = ({ ]; } + const description = + bodySchema?.description ?? + body._def.description ?? + `Response for status ${status}`; + return Object.assign(obj, { [status]: { - description: `Response for status ${status}`, + description, content: { [contentType]: { schema: { @@ -163,17 +175,54 @@ export const getPathsFromRoute = ({ } ); - const pathParameters = route + let pathParameters: OpenAPIV3_1.ParameterObject[] = []; + + if (input?.params) { + const schema = + input.paramsSchema ?? + getJsonSchema({ + schema: input.params, + operationId, + type: 'input-params' + }).properties ?? + {}; + + pathParameters = Object.entries(schema).map(([name, schema]) => { + const _schema = (input.params as ZodObject).shape[ + name + ] as ZodSchema; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return { + name, + in: 'path', + required: !_schema.isOptional(), + schema + } as OpenAPIV3_1.ParameterObject; + }); + + generatedOperationObject.parameters = [ + ...(generatedOperationObject.parameters ?? []), + ...pathParameters + ]; + } + + const automaticPathParameters = route .match(/{([^}]+)}/g) - ?.map((param) => param.replace(/[{}]/g, '')); - - if (pathParameters) { - generatedOperationObject.parameters = pathParameters.map((name) => ({ - name, - in: 'path', - required: true, - schema: { type: 'string' } - })); + ?.map((param) => param.replace(/[{}]/g, '')) + // Filter out path parameters that have been explicitly defined. + .filter((_name) => !pathParameters?.some(({ name }) => name === _name)); + + if (automaticPathParameters?.length) { + generatedOperationObject.parameters = [ + ...(generatedOperationObject.parameters ?? []), + ...(automaticPathParameters.map((name) => ({ + name, + in: 'path', + required: true, + schema: { type: 'string' } + })) as OpenAPIV3_1.ParameterObject[]) + ]; } if (input?.query) { @@ -188,22 +237,19 @@ export const getPathsFromRoute = ({ generatedOperationObject.parameters = [ ...(generatedOperationObject.parameters ?? []), - ...Object.entries(schema) - // Filter out query parameters that have already been added to the path parameters automatically. - .filter(([name]) => !pathParameters?.includes(name)) - .map(([name, schema]) => { - const _schema = (input.query as ZodObject).shape[ - name - ] as ZodSchema; - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return { - name, - in: 'query', - required: !_schema.isOptional(), - schema - } as OpenAPIV3_1.ParameterObject; - }) + ...Object.entries(schema).map(([name, schema]) => { + const _schema = (input.query as ZodObject).shape[ + name + ] as ZodSchema; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return { + name, + in: 'query', + required: !_schema.isOptional(), + schema + } as OpenAPIV3_1.ParameterObject; + }) ]; } diff --git a/packages/next-rest-framework/src/shared/schemas.ts b/packages/next-rest-framework/src/shared/schemas.ts index fa9f116..c521fc8 100644 --- a/packages/next-rest-framework/src/shared/schemas.ts +++ b/packages/next-rest-framework/src/shared/schemas.ts @@ -41,7 +41,7 @@ export const validateSchema = ({ throw Error('Invalid schema.'); }; -type SchemaType = 'input-body' | 'input-query' | 'output-body'; +type SchemaType = 'input-params' | 'input-query' | 'input-body' | 'output-body'; export const getJsonSchema = ({ schema, @@ -60,8 +60,9 @@ export const getJsonSchema = ({ }); } catch (error) { const solutions: Record = { - 'input-body': 'bodySchema', + 'input-params': 'paramsSchema', 'input-query': 'querySchema', + 'input-body': 'bodySchema', 'output-body': 'bodySchema' }; diff --git a/packages/next-rest-framework/tests/app-router/route.test.ts b/packages/next-rest-framework/tests/app-router/route.test.ts index 0e63e03..7af20dd 100644 --- a/packages/next-rest-framework/tests/app-router/route.test.ts +++ b/packages/next-rest-framework/tests/app-router/route.test.ts @@ -211,6 +211,88 @@ describe('route', () => { }); }); + it('returns error for invalid path parameters', async () => { + const params = { + foo: 'bar' + }; + + const { req, context } = createMockRouteRequest({ + method: ValidMethod.POST, + params, + headers: { + 'content-type': 'application/json' + } + }); + + const schema = z.object({ + bar: z.string() + }); + + const res = await route({ + test: routeOperation({ method: 'POST' }) + .input({ + contentType: 'application/json', + params: schema + }) + .handler(() => {}) + }).POST(req, context); + + const json = await res?.json(); + expect(res?.status).toEqual(400); + + const { errors } = validateSchema({ schema, obj: params }); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.invalidPathParameters, + errors + }); + }); + + it('works with valid path parameters', async () => { + const params = { + foo: 'bar' + }; + + const { req, context } = createMockRouteRequest({ + method: ValidMethod.POST, + params, + headers: { + 'content-type': 'application/json' + } + }); + + const schema = z.object({ + foo: z.string() + }); + + const res = await route({ + test: routeOperation({ method: 'POST' }) + .input({ + contentType: 'application/json', + params: schema + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: z.object({ + foo: z.string() + }) + } + ]) + .handler((_req, { params: { foo } }) => { + return TypedNextResponse.json({ foo }); + }) + }).POST(req, context); + + const json = await res?.json(); + expect(res?.status).toEqual(200); + + expect(json).toEqual({ + foo: 'bar' + }); + }); + it('returns error for invalid content-type', async () => { const { req, context } = createMockRouteRequest({ method: ValidMethod.POST, diff --git a/packages/next-rest-framework/tests/pages-router/api-route.test.ts b/packages/next-rest-framework/tests/pages-router/api-route.test.ts index 94b2eb6..183352a 100644 --- a/packages/next-rest-framework/tests/pages-router/api-route.test.ts +++ b/packages/next-rest-framework/tests/pages-router/api-route.test.ts @@ -208,6 +208,87 @@ describe('apiRoute', () => { }); }); + it('returns error for invalid path parameters', async () => { + const params = { + foo: 'bar' + }; + + const { req, res } = createMockApiRouteRequest({ + method: ValidMethod.POST, + params, + headers: { + 'content-type': 'application/json' + } + }); + + const schema = z.object({ + bar: z.string() + }); + + await apiRoute({ + test: apiRouteOperation({ method: 'POST' }) + .input({ + contentType: 'application/json', + params: schema + }) + .handler(() => {}) + })(req, res); + + expect(res.statusCode).toEqual(400); + + const { errors } = validateSchema({ schema, obj: params }); + + expect(res._getJSONData()).toEqual({ + message: DEFAULT_ERRORS.invalidPathParameters, + errors + }); + }); + + it('works with valid path parameters', async () => { + const params = { + foo: 'bar' + }; + + const { req, res } = createMockApiRouteRequest({ + method: ValidMethod.POST, + params, + headers: { + 'content-type': 'application/json' + } + }); + + const schema = z.object({ + foo: z.string() + }); + + await apiRoute({ + test: apiRouteOperation({ method: 'POST' }) + .input({ + contentType: 'application/json', + params: schema + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: z.object({ + foo: z.string() + }) + } + ]) + .handler((req, res) => { + const { foo } = req.query; + res.json({ foo }); + }) + })(req, res); + + expect(res.statusCode).toEqual(200); + + expect(res._getJSONData()).toEqual({ + foo: 'bar' + }); + }); + it('returns error for invalid content-type', async () => { const { req, res } = createMockApiRouteRequest({ method: ValidMethod.POST, diff --git a/packages/next-rest-framework/tests/utils.ts b/packages/next-rest-framework/tests/utils.ts index 8870869..bf4bcc6 100644 --- a/packages/next-rest-framework/tests/utils.ts +++ b/packages/next-rest-framework/tests/utils.ts @@ -83,9 +83,13 @@ export const createMockRpcRouteRequest = ({ export const createMockApiRouteRequest = < Body, - Query = Partial> + Query = Partial>, + Params = BaseParams >( - _reqOptions?: Modify, + _reqOptions?: Modify< + RequestOptions, + { body?: Body; query?: Query; params?: Params } + >, resOptions?: ResponseOptions ) => { const reqOptions = { @@ -98,7 +102,20 @@ export const createMockApiRouteRequest = < }; // @ts-expect-error: The `NextApiRequest` does not satisfy the types for `Request`. - return createMocks(reqOptions, resOptions); + const { req, res } = createMocks( + reqOptions, + resOptions + ); + + // @ts-expect-error: The request query and path parameters need to be set by modifying the request object. + req[Symbol.for('NextInternalRequestMeta') as keyof NextApiRequest] = { + initQuery: _reqOptions?.query, + match: { + params: _reqOptions?.params + } + }; + + return { req, res }; }; export const createMockRpcApiRouteRequest = ({ @@ -113,8 +130,8 @@ export const createMockRpcApiRouteRequest = ({ body?: Body; operation?: string; headers?: Record; -} = {}) => - createMockApiRouteRequest({ +} = {}) => { + return createMockApiRouteRequest({ path: `${path}?operationId=${operation}`, body, method, @@ -122,6 +139,7 @@ export const createMockRpcApiRouteRequest = ({ ...headers } }); +}; export const getExpectedSpec = ({ zodSchema,