From 59756624ac5a8e645768937c522a801832b6a0f3 Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Fri, 14 Mar 2025 19:28:00 +0100 Subject: [PATCH 01/11] added parameter and path info in validation error --- src/core/parsers/openapi-parser.ts | 5 +++-- tests/unit/openapi-parser.spec.ts | 12 ++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/core/parsers/openapi-parser.ts b/src/core/parsers/openapi-parser.ts index 685b3e1..4e856a4 100644 --- a/src/core/parsers/openapi-parser.ts +++ b/src/core/parsers/openapi-parser.ts @@ -14,7 +14,7 @@ import { type AuthParameterLocationDescription, type ParameterLocation, } from "../authentication/http/types.ts"; -import { OpenApiFieldNames } from "../constants.ts"; +import { OPENAPI_FIELD_PREFIX, OpenApiFieldNames } from "../constants.ts"; import type { Resource } from "../policy/entities/resource.ts"; import { createResourceDescriptorSchema } from "../schemas.ts"; import { Route } from "../tests/test-utils.ts"; @@ -112,7 +112,8 @@ export class OpenAPIParser { // validate that required parameters are annotated with resource name and resource access if (resourceDescriptionNeeded && (!resourceAccess || !resourceName)) { throw new Error( - "To describe required resources in routes, both 'resourceName' and 'resourceAccess' must be defined at the same time.", + `To describe required resources in routes, both '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_NAME}' and '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_ACCESS}' must be defined at the same time. + Parameter '${parameter.name}' in path '${path.path}' must be annotated properly.`, ); } diff --git a/tests/unit/openapi-parser.spec.ts b/tests/unit/openapi-parser.spec.ts index 3ebda46..c44d2ec 100644 --- a/tests/unit/openapi-parser.spec.ts +++ b/tests/unit/openapi-parser.spec.ts @@ -120,7 +120,9 @@ test.group("OpenAPIParser", (group) => { const openAPIParser = await OpenAPIParser.create(specUrl, apiBaseUrl); openAPIParser.validateCustomFields(resources); - }).throws(/both 'resourceName' and 'resourceAccess' must be defined/); + }).throws( + /both 'x-act-resource-name' and 'x-act-resource-access' must be defined/, + ); test("should throw when resource name but not resource access is specified", async () => { const resources = [new Resource("User")]; @@ -133,7 +135,9 @@ test.group("OpenAPIParser", (group) => { const openAPIParser = await OpenAPIParser.create(specUrl, apiBaseUrl); openAPIParser.validateCustomFields(resources); - }).throws(/both 'resourceName' and 'resourceAccess' must be defined/); + }).throws( + /both 'x-act-resource-name' and 'x-act-resource-access' must be defined/, + ); test("should throw when resource description is omitted for required parameters", async () => { const resources = [new Resource("User")]; @@ -146,8 +150,8 @@ test.group("OpenAPIParser", (group) => { openAPIParser.validateCustomFields(resources); }).throws( - "To describe required resources in routes, both 'resourceName' and 'resourceAccess' must be defined at the same time.", - ); + /To describe required resources in routes, both 'x-act-resource-name' and 'x-act-resource-access' must be defined at the same time./, + ); // todo: also check for "Parameter 'id' in path '/admin/users/{id}' must be annotated properly." test("should properly combine api base url containing path with given path", async ({ expect, From b2f5d9fd3e6733f5124804d53645814e07886bfe Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Fri, 14 Mar 2025 22:48:58 +0100 Subject: [PATCH 02/11] added parseZodSchema utility function that parses the given schema and provides a custom error format --- src/core/parsers/openapi-parser.ts | 65 +++++++++++++------------ src/core/schemas.ts | 76 ++++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 43 deletions(-) diff --git a/src/core/parsers/openapi-parser.ts b/src/core/parsers/openapi-parser.ts index 4e856a4..c1119a9 100644 --- a/src/core/parsers/openapi-parser.ts +++ b/src/core/parsers/openapi-parser.ts @@ -16,7 +16,7 @@ import { } from "../authentication/http/types.ts"; import { OPENAPI_FIELD_PREFIX, OpenApiFieldNames } from "../constants.ts"; import type { Resource } from "../policy/entities/resource.ts"; -import { createResourceDescriptorSchema } from "../schemas.ts"; +import { createResourceDescriptorSchema, parseZodSchema } from "../schemas.ts"; import { Route } from "../tests/test-utils.ts"; import type { AuthEndpointInformation } from "../types.ts"; import { @@ -89,8 +89,6 @@ export class OpenAPIParser { // todo: should this be part of the creation process? validateCustomFields(resources: Array) { const resourceNames = resources.map((resource) => resource.getName()); - const resourceDescriptorSchema = - createResourceDescriptorSchema(resourceNames); this.getPaths().forEach((path) => { path.getParameters().forEach((parameter) => { @@ -105,37 +103,46 @@ export class OpenAPIParser { const parameterSchema = parameter.schema as SchemaObject; const parameterDefaultProvided = Boolean(parameterSchema.default); - const resourceDescriptionNeeded = + const descriptorsRequired = Boolean(parameter.required) && !parameterDefaultProvided; - // todo: use default parameter values in requests when they are provided for required params - // validate that required parameters are annotated with resource name and resource access - if (resourceDescriptionNeeded && (!resourceAccess || !resourceName)) { - throw new Error( - `To describe required resources in routes, both '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_NAME}' and '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_ACCESS}' must be defined at the same time. - Parameter '${parameter.name}' in path '${path.path}' must be annotated properly.`, - ); - } + // todo: use default parameter values in requests when they are provided for required params - // todo: better custom error message - // create a ResourceDescriptorParser - // it should accept path.schema/parameter.schema and provide a nicer error message - resourceDescriptorSchema.parse({ - resourceName, - resourceAccess, - }); + parseZodSchema( + createResourceDescriptorSchema({ + allowedResourceNames: resourceNames, + descriptorsRequired, + }), + { + resourceName, + resourceAccess, + }, + [ + "To describe", + descriptorsRequired ? "required" : "", + `resources in routes, both '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_NAME}' and '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_ACCESS}' must be defined at the same time. +Parameter '${parameter.name}' in path '${path.path}' must be annotated properly.`, + ].join(" "), + ); }); - resourceDescriptorSchema.parse({ - resourceName: getOpenApiField( - path.schema, - OpenApiFieldNames.RESOURCE_NAME, - ), - resourceAccess: getOpenApiField( - path.schema, - OpenApiFieldNames.RESOURCE_ACCESS, - ), - }); + parseZodSchema( + createResourceDescriptorSchema({ + allowedResourceNames: resourceNames, + }), + { + resourceName: getOpenApiField( + path.schema, + OpenApiFieldNames.RESOURCE_NAME, + ), + resourceAccess: getOpenApiField( + path.schema, + OpenApiFieldNames.RESOURCE_ACCESS, + ), + }, + `To describe resources in routes, both '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_NAME}' and '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_ACCESS}' must be defined at the same time. + Path '${path.path}' must be annotated properly.`, + ); }); } diff --git a/src/core/schemas.ts b/src/core/schemas.ts index c198754..15e5e9d 100644 --- a/src/core/schemas.ts +++ b/src/core/schemas.ts @@ -1,23 +1,37 @@ -import { z } from "zod"; +import { z, ZodError, ZodSchema, type ZodIssue } from "zod"; + +export const createResourceDescriptorSchema = ({ + allowedResourceNames, + descriptorsRequired = false, +}: { + allowedResourceNames: Array; + descriptorsRequired?: boolean; +}) => { + const resourceAccessSchema = z.enum(["create", "read", "update", "delete"]); + const resourceNameSchema = z.enum( + allowedResourceNames as [string, ...Array], + ); -export const createResourceDescriptorSchema = ( - allowedResourceNames: Array, -) => { return z .object({ - resourceAccess: z.enum(["create", "read", "update", "delete"]).optional(), // todo: unify naming access/action etc. - resourceName: z - .enum(allowedResourceNames as [string, ...Array]) - .optional(), + resourceAccess: descriptorsRequired + ? resourceAccessSchema + : resourceAccessSchema.optional(), + resourceName: descriptorsRequired + ? resourceNameSchema + : resourceNameSchema.optional(), }) .refine( - (data) => - (data.resourceAccess !== undefined && - data.resourceName !== undefined) || - (data.resourceAccess === undefined && data.resourceName === undefined), + (data) => { + const bothOrOneIsMissing = + (data.resourceAccess === undefined && + data.resourceName === undefined) || + (data.resourceAccess !== undefined && + data.resourceName !== undefined); + + return bothOrOneIsMissing; + }, { - message: - "To describe resources in routes, both 'resourceName' and 'resourceAccess' must be defined at the same time.", path: ["resourceAccess", "resourceName"], }, ); @@ -28,3 +42,37 @@ export const AuthFieldSchema = z type: z.enum(["identifier", "password", "token"]), }) .optional(); + +// from https://stackoverflow.com/a/76642589/13156621 +const formatZodIssue = (issue: ZodIssue): string => { + const { path, message } = issue; + const pathString = path.join("."); + + return `${pathString}: ${message}`; +}; + +const formatZodError = (error: ZodError, prefix: string): string => { + const { issues } = error; + if (issues[0] === undefined) return prefix; + + const firstIssue = formatZodIssue(issues[0]); + const additionalIssuesHint = + issues.length > 1 ? ` (and ${issues.length - 1} more issues)` : ""; + + return `${prefix}\n\n${firstIssue}${additionalIssuesHint}`; +}; + +export const parseZodSchema = ( + schema: ZodSchema, + data: unknown, + prefix: string, +): SchemaType => { + try { + return schema.parse(data); + } catch (error) { + if (error instanceof ZodError) { + throw new TypeError(formatZodError(error, prefix)); + } + throw error; + } +}; From 8c768205196bfa47cee1180314a64c7e75f8a1d9 Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Fri, 14 Mar 2025 22:52:43 +0100 Subject: [PATCH 03/11] added default prefix for zod schema parsing function --- src/core/schemas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/schemas.ts b/src/core/schemas.ts index 15e5e9d..fb89e96 100644 --- a/src/core/schemas.ts +++ b/src/core/schemas.ts @@ -65,7 +65,7 @@ const formatZodError = (error: ZodError, prefix: string): string => { export const parseZodSchema = ( schema: ZodSchema, data: unknown, - prefix: string, + prefix = "An error occurred while trying to parse the provided data.", ): SchemaType => { try { return schema.parse(data); From 4ef47cf2632ee0b797deff411cd7f7b6a0f4f86b Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Fri, 14 Mar 2025 23:15:55 +0100 Subject: [PATCH 04/11] prepare validateCustomFields function for validating request body parameters --- src/core/parsers/openapi-parser.ts | 54 ++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/src/core/parsers/openapi-parser.ts b/src/core/parsers/openapi-parser.ts index c1119a9..72cfa1d 100644 --- a/src/core/parsers/openapi-parser.ts +++ b/src/core/parsers/openapi-parser.ts @@ -91,7 +91,7 @@ export class OpenAPIParser { const resourceNames = resources.map((resource) => resource.getName()); this.getPaths().forEach((path) => { - path.getParameters().forEach((parameter) => { + const pathParameters = path.getParameters().map((parameter) => { const resourceAccess = getOpenApiField( parameter, OpenApiFieldNames.RESOURCE_ACCESS, @@ -108,24 +108,44 @@ export class OpenAPIParser { // todo: use default parameter values in requests when they are provided for required params - parseZodSchema( - createResourceDescriptorSchema({ - allowedResourceNames: resourceNames, - descriptorsRequired, - }), - { - resourceName, - resourceAccess, - }, - [ - "To describe", - descriptorsRequired ? "required" : "", - `resources in routes, both '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_NAME}' and '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_ACCESS}' must be defined at the same time. -Parameter '${parameter.name}' in path '${path.path}' must be annotated properly.`, - ].join(" "), - ); + return { + resourceName, + resourceAccess, + descriptorsRequired, + parameterName: parameter.name, + parameterLocation: parameter.in, + }; }); + const allResourceLocations = [...pathParameters]; + + allResourceLocations.forEach( + ({ + resourceName, + resourceAccess, + descriptorsRequired, + parameterName, + parameterLocation, + }) => { + parseZodSchema( + createResourceDescriptorSchema({ + allowedResourceNames: resourceNames, + descriptorsRequired, + }), + { + resourceName, + resourceAccess, + }, + [ + "To describe", + descriptorsRequired ? "required" : "", + `resources in routes, both '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_NAME}' and '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_ACCESS}' must be defined at the same time. +Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path}' must be annotated properly.`, + ].join(" "), + ); + }, + ); + parseZodSchema( createResourceDescriptorSchema({ allowedResourceNames: resourceNames, From 3cd8a8c30aa760eab182a38af35c2f9c6a9e120e Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Fri, 14 Mar 2025 23:33:49 +0100 Subject: [PATCH 05/11] introduced getParametrizedResources method used for validating request body resource annotation & renamed path to operation --- src/core/parsers/openapi-parser.ts | 214 ++++++++++++++--------------- src/core/types.ts | 2 +- 2 files changed, 107 insertions(+), 109 deletions(-) diff --git a/src/core/parsers/openapi-parser.ts b/src/core/parsers/openapi-parser.ts index 72cfa1d..dbdacd9 100644 --- a/src/core/parsers/openapi-parser.ts +++ b/src/core/parsers/openapi-parser.ts @@ -37,6 +37,9 @@ export type ResourceLocationDescriptor = { parameterLocation?: ParameterLocation; }; +type Operations = ReturnType; +type Operation = Operations[0]; + export class OpenAPIParser { private constructor( private readonly openApiSource: Oas, @@ -90,36 +93,10 @@ export class OpenAPIParser { validateCustomFields(resources: Array) { const resourceNames = resources.map((resource) => resource.getName()); - this.getPaths().forEach((path) => { - const pathParameters = path.getParameters().map((parameter) => { - const resourceAccess = getOpenApiField( - parameter, - OpenApiFieldNames.RESOURCE_ACCESS, - ); - const resourceName = getOpenApiField( - parameter, - OpenApiFieldNames.RESOURCE_NAME, - ); + this.getOperations().forEach((operation) => { + const parametrizedResources = this.getParametrizedResources(operation); - const parameterSchema = parameter.schema as SchemaObject; - const parameterDefaultProvided = Boolean(parameterSchema.default); - const descriptorsRequired = - Boolean(parameter.required) && !parameterDefaultProvided; - - // todo: use default parameter values in requests when they are provided for required params - - return { - resourceName, - resourceAccess, - descriptorsRequired, - parameterName: parameter.name, - parameterLocation: parameter.in, - }; - }); - - const allResourceLocations = [...pathParameters]; - - allResourceLocations.forEach( + parametrizedResources.forEach( ({ resourceName, resourceAccess, @@ -140,7 +117,7 @@ export class OpenAPIParser { "To describe", descriptorsRequired ? "required" : "", `resources in routes, both '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_NAME}' and '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_ACCESS}' must be defined at the same time. -Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path}' must be annotated properly.`, +Parameter '${parameterName}' of type '${parameterLocation}' in path '${operation.path}' must be annotated properly.`, ].join(" "), ); }, @@ -152,16 +129,16 @@ Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path }), { resourceName: getOpenApiField( - path.schema, + operation.schema, OpenApiFieldNames.RESOURCE_NAME, ), resourceAccess: getOpenApiField( - path.schema, + operation.schema, OpenApiFieldNames.RESOURCE_ACCESS, ), }, `To describe resources in routes, both '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_NAME}' and '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_ACCESS}' must be defined at the same time. - Path '${path.path}' must be annotated properly.`, +Path '${operation.path}' must be annotated properly.`, ); }); } @@ -206,7 +183,7 @@ Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path } } - getPaths(): ReturnType { + getOperations(): Operations { const oasPaths = this.openApiSource.getPaths(); return this.transformPathsSchema(oasPaths); @@ -219,9 +196,9 @@ Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path getPathResourceMappings(filterAuthEndpointsOut = true) { // todo: ensure that validation happens before // parameterName, parameterLocation, resourceAccess resourceName need to be valid - const paths = this.getPaths(); + const paths = this.getOperations(); - const filteredPaths = filterAuthEndpointsOut + const filteredOperations = filterAuthEndpointsOut ? paths.filter((path) => { const isAuthEndpoint = Boolean( getOpenApiField(path.schema, OpenApiFieldNames.AUTH_ENDPOINT), @@ -231,79 +208,16 @@ Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path }) : paths; - return filteredPaths.map((path) => { - // todo: getParametersAsJSONSchema seems to return null (which is not present in the type definition), open an issue - - // unfortunately, getParametersAsJSONSchema strips x-act fields for other parameter types such as path or query - // this is why there are separate calls for requestBody and parameters - // todo: maybe find a better way to handle this/open an issue - const resourcesFromRequestBody = path.hasRequestBody() - ? path.getParametersAsJSONSchema().flatMap((parameterType) => { - const parameterLocation = parameterType.type as ParameterLocation; - - if ( - !parameterType.schema.properties || - parameterLocation !== "body" - ) { - return []; - } - - return Object.entries(parameterType.schema.properties).flatMap( - ([parameterName, property]) => { - if (typeof property !== "object") { - return []; - } - - const resourceName = getOpenApiField( - property, - OpenApiFieldNames.RESOURCE_NAME, - ) as string; - - const resourceAccess = getOpenApiField( - property, - OpenApiFieldNames.RESOURCE_ACCESS, - ) as string; - - if (!resourceName || !resourceAccess) { - return []; - } - - return { - resourceName, - resourceAccess, - parameterName, - parameterLocation, - }; - }, - ); - }) - : []; - - const resourcesFromParameters = path.getParameters().map((parameter) => ({ - parameterName: parameter.name, - parameterLocation: parameter.in, - resourceName: getOpenApiField( - parameter, - OpenApiFieldNames.RESOURCE_NAME, - ) as string, // todo: replace this with zod parsing - resourceAccess: getOpenApiField( - parameter, - OpenApiFieldNames.RESOURCE_ACCESS, - ) as string, - })); - - const parametrizedResources = [ - ...resourcesFromRequestBody, - ...resourcesFromParameters, - ]; + return filteredOperations.map((operation) => { + const parametrizedResources = this.getParametrizedResources(operation); // todo: at the moment it is considered that there can be at most one non-parametrized resource per path (e.g. /users) const nonParametrizedResourceName = getOpenApiField( - path.schema, + operation.schema, OpenApiFieldNames.RESOURCE_NAME, ) as string; const nonParametrizedResourceAccess = getOpenApiField( - path.schema, + operation.schema, OpenApiFieldNames.RESOURCE_ACCESS, ) as string; const nonParametrizedResources = @@ -316,7 +230,7 @@ Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path ] : []; - const securityRequirements = path.getSecurity(); + const securityRequirements = operation.getSecurity(); const isPublicPath = securityRequirements.length === 0; const resources: Array = [ @@ -325,14 +239,98 @@ Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path ]; return { - path: path.path, - method: path.method, + path: operation.path, + method: operation.method, isPublicPath, resources, }; }); } + /** + * Returns all resources for the given path enriched with resource information + * coming from various parameters and the request body + * + * @private + * @param operation + */ + private getParametrizedResources(operation: Operation) { + // todo: getParametersAsJSONSchema seems to return null (which is not present in the type definition), open an issue + + // unfortunately, getParametersAsJSONSchema strips x-act fields for other parameter types such as path or query + // this is why there are separate calls for requestBody and parameters + // todo: maybe find a better way to handle this/open an issue + const resourcesFromRequestBody = operation.hasRequestBody() + ? operation.getParametersAsJSONSchema().flatMap((parameterType) => { + const parameterLocation = parameterType.type as ParameterLocation; + + if ( + !parameterType.schema.properties || + parameterLocation !== "body" + ) { + return []; + } + + return Object.entries(parameterType.schema.properties).flatMap( + ([parameterName, property]) => { + if (typeof property !== "object") { + return []; + } + + const resourceName = getOpenApiField( + property, + OpenApiFieldNames.RESOURCE_NAME, + ) as string; + + const resourceAccess = getOpenApiField( + property, + OpenApiFieldNames.RESOURCE_ACCESS, + ) as string; + + if (!resourceName || !resourceAccess) { + return []; + } + + // todo: calculate this + const descriptorsRequired = true; + + return { + resourceName, + resourceAccess, + descriptorsRequired, + parameterName, + parameterLocation, + }; + }, + ); + }) + : []; + + const resourcesFromParameters = operation + .getParameters() + .map((parameter) => { + const parameterSchema = parameter.schema as SchemaObject; + const parameterDefaultProvided = Boolean(parameterSchema.default); + const descriptorsRequired = + Boolean(parameter.required) && !parameterDefaultProvided; + return { + parameterName: parameter.name, + parameterLocation: parameter.in, + resourceName: getOpenApiField( + parameter, + OpenApiFieldNames.RESOURCE_NAME, + ) as string, // todo: replace this with zod parsing + resourceAccess: getOpenApiField( + parameter, + OpenApiFieldNames.RESOURCE_ACCESS, + ) as string, + descriptorsRequired, + }; + }); + + return [...resourcesFromRequestBody, ...resourcesFromParameters]; + } + /** * Transforms the paths schema from Oas to a minimal schema containing only * the necessary information @@ -365,7 +363,7 @@ Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path return null; } - const paths = this.getPaths(); + const paths = this.getOperations(); // todo: handle cases when 0 or more than 1 is found // maybe 0 or more than 1 are cases to be handled by validation / parsing @@ -477,7 +475,7 @@ Parameter '${parameterName}' of type '${parameterLocation}' in path '${path.path // todo: move out filtering part to a separate function (to be reused for identifier/password extract function) private getTokenParameterDescription( - authEndpoint: ReturnType[0], + authEndpoint: ReturnType[0], ): AuthParameterLocationDescription { const responseStatusCodes = authEndpoint.getResponseStatusCodes(); diff --git a/src/core/types.ts b/src/core/types.ts index 055c8bc..ea7e100 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -2,7 +2,7 @@ import type { AuthParameterLocationDescription } from "./authentication/http/typ import type { OpenAPIParser } from "./parsers/openapi-parser.ts"; export type AuthEndpointInformation = { - authEndpoint: ReturnType[0]; + authEndpoint: ReturnType[0]; authRequestParameterDescription: { identifier: AuthParameterLocationDescription; password: AuthParameterLocationDescription; From 4d291edbd1ebdfa51d6270564331f666d2f28c32 Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Fri, 14 Mar 2025 23:37:31 +0100 Subject: [PATCH 06/11] added update user to openapi fixture --- tests/fixtures/openapi.json | 89 +++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/fixtures/openapi.json b/tests/fixtures/openapi.json index 3bc0348..5a71505 100644 --- a/tests/fixtures/openapi.json +++ b/tests/fixtures/openapi.json @@ -231,6 +231,95 @@ "cookieAuthentication": [] } ] + }, + "patch" : { + "summary" : "Benutzer updaten", + "requestBody" : { + "required" : true, + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "description" : "Name des Benutzers", + "example" : "Max Mustermann" + }, + "id" : { + "type" : "string", + "description" : "ID des Benutzers", + "x-act" : { + "resource-name" : "User", + "resource-access" : "update" + } + }, + "password" : { + "type" : "string", + "format" : "password", + "description" : "Passwort des Benutzers", + "example" : "secretpassword" + } + } + } + } + } + }, + "responses" : { + "201" : { + "description" : "Benutzer erfolgreich erstellt", + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "example" : 1 + }, + "name" : { + "type" : "string", + "example" : "Max Mustermann" + }, + "email" : { + "type" : "string", + "example" : "max@beispiel.de" + } + } + } + } + } + }, + "400" : { + "description" : "Ungültige Eingabe", + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "errors" : { + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "message" : { + "type" : "string", + "example" : "Ungültige E-Mail-Adresse" + } + } + } + } + } + } + } + } + } + }, + "security" : [ { + "bearerHttpAuthentication" : [ ] + }, { + "cookieAuthentication" : [ ] + } ] } }, "/admin/users/{id}": { From c8b6a7575e52e305aab0afcb18220f64316e88e6 Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Sat, 15 Mar 2025 00:07:21 +0100 Subject: [PATCH 07/11] show issue instead of issues for 1 additional issue --- src/core/schemas.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/schemas.ts b/src/core/schemas.ts index fb89e96..5b5036c 100644 --- a/src/core/schemas.ts +++ b/src/core/schemas.ts @@ -56,8 +56,12 @@ const formatZodError = (error: ZodError, prefix: string): string => { if (issues[0] === undefined) return prefix; const firstIssue = formatZodIssue(issues[0]); + + const additionalIssuesCount = issues.length - 1; const additionalIssuesHint = - issues.length > 1 ? ` (and ${issues.length - 1} more issues)` : ""; + additionalIssuesCount >= 1 + ? ` (and ${additionalIssuesCount} more ${additionalIssuesCount > 1 ? "issues" : "issue"})` + : ""; return `${prefix}\n\n${firstIssue}${additionalIssuesHint}`; }; From 3d781c690fc9ac800dc936b28485f258fbd040bb Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Sat, 15 Mar 2025 00:13:05 +0100 Subject: [PATCH 08/11] make sure all resources are returned inside getParametrizedResources --- src/core/parsers/openapi-parser.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/parsers/openapi-parser.ts b/src/core/parsers/openapi-parser.ts index dbdacd9..5eead95 100644 --- a/src/core/parsers/openapi-parser.ts +++ b/src/core/parsers/openapi-parser.ts @@ -287,10 +287,6 @@ Path '${operation.path}' must be annotated properly.`, OpenApiFieldNames.RESOURCE_ACCESS, ) as string; - if (!resourceName || !resourceAccess) { - return []; - } - // todo: calculate this const descriptorsRequired = true; @@ -313,6 +309,7 @@ Path '${operation.path}' must be annotated properly.`, const parameterDefaultProvided = Boolean(parameterSchema.default); const descriptorsRequired = Boolean(parameter.required) && !parameterDefaultProvided; + return { parameterName: parameter.name, parameterLocation: parameter.in, From de676d6434850ec6eaac72097f2a9304102aff11 Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Sat, 15 Mar 2025 00:13:19 +0100 Subject: [PATCH 09/11] added test cases for request body validation --- tests/unit/openapi-parser.spec.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/unit/openapi-parser.spec.ts b/tests/unit/openapi-parser.spec.ts index c44d2ec..a240bec 100644 --- a/tests/unit/openapi-parser.spec.ts +++ b/tests/unit/openapi-parser.spec.ts @@ -153,6 +153,37 @@ test.group("OpenAPIParser", (group) => { /To describe required resources in routes, both 'x-act-resource-name' and 'x-act-resource-access' must be defined at the same time./, ); // todo: also check for "Parameter 'id' in path '/admin/users/{id}' must be annotated properly." + test("should throw when resource name is invalid in request body property", async () => { + const resources = [new Resource("User")]; + + currentSpec.paths["/admin/users"].patch.requestBody.content[ + "application/json" + ].schema.properties.id["x-act"]["resource-name"] = "test"; + + const openAPIParser = await OpenAPIParser.create(specUrl, apiBaseUrl); + + openAPIParser.validateCustomFields(resources); + }).throws(/Expected 'User', received 'test'/); + + test("should throw when resource annotation is missing in required request body property", async () => { + const resources = [new Resource("User")]; + + // @ts-expect-error mutation of spec is expected + currentSpec.paths["/admin/users"].patch.requestBody.content[ + "application/json" + ].schema.properties.id["x-act"] = undefined; + + ( + currentSpec.paths["/admin/users"].patch.requestBody.content[ + "application/json" + ].schema.properties.id as any + ).required = true; + + const openAPIParser = await OpenAPIParser.create(specUrl, apiBaseUrl); + + openAPIParser.validateCustomFields(resources); + }).throws(/To describe required reasoures in routes/); + test("should properly combine api base url containing path with given path", async ({ expect, }) => { From 64c93f7d5a1c4a7788bbee62a130eab6d5432082 Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Sat, 15 Mar 2025 00:26:25 +0100 Subject: [PATCH 10/11] since there are no required individual request body properties, set descriptorsRequired to false and removed testcase --- src/core/parsers/openapi-parser.ts | 7 ++----- src/core/schemas.ts | 20 +++++++------------- tests/unit/openapi-parser.spec.ts | 19 ------------------- 3 files changed, 9 insertions(+), 37 deletions(-) diff --git a/src/core/parsers/openapi-parser.ts b/src/core/parsers/openapi-parser.ts index 5eead95..c785bbf 100644 --- a/src/core/parsers/openapi-parser.ts +++ b/src/core/parsers/openapi-parser.ts @@ -117,7 +117,7 @@ export class OpenAPIParser { "To describe", descriptorsRequired ? "required" : "", `resources in routes, both '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_NAME}' and '${OPENAPI_FIELD_PREFIX}-${OpenApiFieldNames.RESOURCE_ACCESS}' must be defined at the same time. -Parameter '${parameterName}' of type '${parameterLocation}' in path '${operation.path}' must be annotated properly.`, +Parameter '${parameterName}' of type '${parameterLocation}' in path '${operation.method.toUpperCase()} ${operation.path}' must be annotated properly.`, ].join(" "), ); }, @@ -287,13 +287,10 @@ Path '${operation.path}' must be annotated properly.`, OpenApiFieldNames.RESOURCE_ACCESS, ) as string; - // todo: calculate this - const descriptorsRequired = true; - return { resourceName, resourceAccess, - descriptorsRequired, + descriptorsRequired: false, parameterName, parameterLocation, }; diff --git a/src/core/schemas.ts b/src/core/schemas.ts index 5b5036c..d1cf2ad 100644 --- a/src/core/schemas.ts +++ b/src/core/schemas.ts @@ -21,20 +21,14 @@ export const createResourceDescriptorSchema = ({ ? resourceNameSchema : resourceNameSchema.optional(), }) - .refine( - (data) => { - const bothOrOneIsMissing = - (data.resourceAccess === undefined && - data.resourceName === undefined) || - (data.resourceAccess !== undefined && - data.resourceName !== undefined); + .refine((data) => { + const bothOrOneIsMissing = + (data.resourceAccess === undefined && + data.resourceName === undefined) || + (data.resourceAccess !== undefined && data.resourceName !== undefined); - return bothOrOneIsMissing; - }, - { - path: ["resourceAccess", "resourceName"], - }, - ); + return bothOrOneIsMissing; + }); }; export const AuthFieldSchema = z diff --git a/tests/unit/openapi-parser.spec.ts b/tests/unit/openapi-parser.spec.ts index a240bec..e7f5df5 100644 --- a/tests/unit/openapi-parser.spec.ts +++ b/tests/unit/openapi-parser.spec.ts @@ -165,25 +165,6 @@ test.group("OpenAPIParser", (group) => { openAPIParser.validateCustomFields(resources); }).throws(/Expected 'User', received 'test'/); - test("should throw when resource annotation is missing in required request body property", async () => { - const resources = [new Resource("User")]; - - // @ts-expect-error mutation of spec is expected - currentSpec.paths["/admin/users"].patch.requestBody.content[ - "application/json" - ].schema.properties.id["x-act"] = undefined; - - ( - currentSpec.paths["/admin/users"].patch.requestBody.content[ - "application/json" - ].schema.properties.id as any - ).required = true; - - const openAPIParser = await OpenAPIParser.create(specUrl, apiBaseUrl); - - openAPIParser.validateCustomFields(resources); - }).throws(/To describe required reasoures in routes/); - test("should properly combine api base url containing path with given path", async ({ expect, }) => { From cd0cc9695e7b180961bd6ea928193d96b41607cb Mon Sep 17 00:00:00 2001 From: Niklas Haug Date: Sat, 15 Mar 2025 00:33:05 +0100 Subject: [PATCH 11/11] formatted with prettier --- tests/fixtures/openapi.json | 125 ++++++++++++++++++------------------ 1 file changed, 64 insertions(+), 61 deletions(-) diff --git a/tests/fixtures/openapi.json b/tests/fixtures/openapi.json index 5a71505..292aef2 100644 --- a/tests/fixtures/openapi.json +++ b/tests/fixtures/openapi.json @@ -232,79 +232,79 @@ } ] }, - "patch" : { - "summary" : "Benutzer updaten", - "requestBody" : { - "required" : true, - "content" : { - "application/json" : { - "schema" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "description" : "Name des Benutzers", - "example" : "Max Mustermann" + "patch": { + "summary": "Benutzer updaten", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name des Benutzers", + "example": "Max Mustermann" }, - "id" : { - "type" : "string", - "description" : "ID des Benutzers", - "x-act" : { - "resource-name" : "User", - "resource-access" : "update" + "id": { + "type": "string", + "description": "ID des Benutzers", + "x-act": { + "resource-name": "User", + "resource-access": "update" } }, - "password" : { - "type" : "string", - "format" : "password", - "description" : "Passwort des Benutzers", - "example" : "secretpassword" + "password": { + "type": "string", + "format": "password", + "description": "Passwort des Benutzers", + "example": "secretpassword" } } } } } }, - "responses" : { - "201" : { - "description" : "Benutzer erfolgreich erstellt", - "content" : { - "application/json" : { - "schema" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "integer", - "example" : 1 + "responses": { + "201": { + "description": "Benutzer erfolgreich erstellt", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 }, - "name" : { - "type" : "string", - "example" : "Max Mustermann" + "name": { + "type": "string", + "example": "Max Mustermann" }, - "email" : { - "type" : "string", - "example" : "max@beispiel.de" + "email": { + "type": "string", + "example": "max@beispiel.de" } } } } } }, - "400" : { - "description" : "Ungültige Eingabe", - "content" : { - "application/json" : { - "schema" : { - "type" : "object", - "properties" : { - "errors" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "message" : { - "type" : "string", - "example" : "Ungültige E-Mail-Adresse" + "400": { + "description": "Ungültige Eingabe", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Ungültige E-Mail-Adresse" } } } @@ -315,11 +315,14 @@ } } }, - "security" : [ { - "bearerHttpAuthentication" : [ ] - }, { - "cookieAuthentication" : [ ] - } ] + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "cookieAuthentication": [] + } + ] } }, "/admin/users/{id}": {