diff --git a/package-lock.json b/package-lock.json index 57320d2..3f80fed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zibri", - "version": "2.1.6", + "version": "2.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zibri", - "version": "2.1.6", + "version": "2.1.7", "license": "MIT", "dependencies": { "@fastify/busboy": "^3.2.0", diff --git a/package.json b/package.json index df671e3..eef5059 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zibri", - "version": "2.1.6", + "version": "2.1.7", "main": "./dist/cjs/index.js", "types": "./dist/cjs/index.d.ts", "module": "./dist/esm/index.mjs", diff --git a/src/entity/generation/generate-entity-files-for-provider.function.ts b/src/entity/generation/generate-entity-files-for-provider.function.ts index ce11114..c522c96 100644 --- a/src/entity/generation/generate-entity-files-for-provider.function.ts +++ b/src/entity/generation/generate-entity-files-for-provider.function.ts @@ -52,41 +52,42 @@ export async function generateEntityFilesForProvider( ): Promise { const definition: OpenApiDefinition = await provider.resolveSpec(); const schemas: OpenApiSchemas = definition.components?.schemas ?? {}; - // TODO - for (const [key, path] of Object.entries(definition.paths ?? {})) { - for (const method of OP_METHODS) { - const operation: OpenApiOperation | undefined = path[method]; - if (!operation) { - continue; - } + if (provider.generateSchemasFromPaths) { + for (const [key, path] of Object.entries(definition.paths ?? {})) { + for (const method of OP_METHODS) { + const operation: OpenApiOperation | undefined = path[method]; + if (!operation) { + continue; + } - const baseFromOp: string = operation.operationId ?? toPascalCase(key); + const baseFromOp: string = operation.operationId ?? toPascalCase(key); - if (operation.requestBody && !('$ref' in operation.requestBody)) { - for (const media of Object.values(operation.requestBody.content ?? {})) { - if (!media.schema) { - continue; + if (operation.requestBody && !('$ref' in operation.requestBody)) { + for (const media of Object.values(operation.requestBody.content ?? {})) { + if (!media.schema) { + continue; + } + collectSchemaCandidate(media.schema, baseFromOp, schemas); } - collectSchemaCandidate(media.schema, baseFromOp, schemas); } - } - for (const value of Object.values(operation.responses ?? {})) { + for (const value of Object.values(operation.responses ?? {})) { // eslint-disable-next-line typescript/no-unsafe-assignment - const response: OpenApiResponseObject | OpenApiReferenceObject | undefined = value; - if (response == undefined) { - continue; - } - if ('$ref' in response) { + const response: OpenApiResponseObject | OpenApiReferenceObject | undefined = value; + if (response == undefined) { + continue; + } + if ('$ref' in response) { // ignore response $ref (could point to components.responses); responses often wrap schemas inside content - continue; - } - for (const media of Object.values(response.content ?? {})) { - if (!media.schema) { continue; } - // build name hint using status code if available in parent loop? we only have the schema and baseFromOp - collectSchemaCandidate(media.schema, baseFromOp, schemas); + for (const media of Object.values(response.content ?? {})) { + if (!media.schema) { + continue; + } + // build name hint using status code if available in parent loop? we only have the schema and baseFromOp + collectSchemaCandidate(media.schema, baseFromOp, schemas); + } } } } diff --git a/src/entity/generation/generate-entity-files-for-provider.test.ts b/src/entity/generation/generate-entity-files-for-provider.test.ts index e9f2205..8cd10ff 100644 --- a/src/entity/generation/generate-entity-files-for-provider.test.ts +++ b/src/entity/generation/generate-entity-files-for-provider.test.ts @@ -9,7 +9,7 @@ import { toKebabCase, toPascalCase } from '../../utilities'; // small InlineProvider so tests are offline and deterministic class InlineProvider implements EntityGenerationProvider { - constructor(public prefix: string, private readonly spec: OpenApiDefinition, public markAsEntities: boolean = true) {} + constructor(readonly prefix: string, private readonly spec: OpenApiDefinition, readonly generateSchemasFromPaths: boolean = true, readonly markAsEntities: boolean = true) {} // eslint-disable-next-line typescript/require-await async resolveSpec(): Promise { return this.spec; diff --git a/src/entity/generation/providers/entity-generation-provider.interface.ts b/src/entity/generation/providers/entity-generation-provider.interface.ts index 5b54a01..69eba6e 100644 --- a/src/entity/generation/providers/entity-generation-provider.interface.ts +++ b/src/entity/generation/providers/entity-generation-provider.interface.ts @@ -12,6 +12,10 @@ export interface EntityGenerationProvider { * Whether or not the generated entities should be marked with @Entity. */ readonly markAsEntities: boolean, + /** + * Whether or not to generate schemas from openapi paths. + */ + readonly generateSchemasFromPaths: boolean, /** * The method that actually resolves the open api spec that is then later on used to generate the entities. */ diff --git a/src/entity/generation/providers/open-api-file.provider.ts b/src/entity/generation/providers/open-api-file.provider.ts index e7dafcf..656543a 100644 --- a/src/entity/generation/providers/open-api-file.provider.ts +++ b/src/entity/generation/providers/open-api-file.provider.ts @@ -9,7 +9,12 @@ import { OpenApiDefinition } from '../../../open-api'; * An entity generation provider using a local open api file. */ export class OpenApiFileProvider implements EntityGenerationProvider { - constructor(readonly prefix: string, protected readonly filePath: string, readonly markAsEntities: boolean = true) {} + constructor( + readonly prefix: string, + protected readonly filePath: string, + readonly generateSchemasFromPaths: boolean = false, + readonly markAsEntities: boolean = false + ) {} // eslint-disable-next-line jsdoc/require-jsdoc async resolveSpec(): Promise { diff --git a/src/entity/generation/providers/open-api-url.provider.ts b/src/entity/generation/providers/open-api-url.provider.ts index bee429a..42bffc8 100644 --- a/src/entity/generation/providers/open-api-url.provider.ts +++ b/src/entity/generation/providers/open-api-url.provider.ts @@ -15,7 +15,12 @@ export class OpenApiUrlProvider implements EntityGenerationProvider { return inject(ZIBRI_DI_TOKENS.HTTP_CLIENT); } - constructor(readonly prefix: string, protected readonly baseUrl: string, readonly markAsEntities: boolean = true) {} + constructor( + readonly prefix: string, + protected readonly baseUrl: string, + readonly generateSchemasFromPaths: boolean = false, + readonly markAsEntities: boolean = false + ) {} // eslint-disable-next-line jsdoc/require-jsdoc async resolveSpec(): Promise { diff --git a/src/http-client/http-client.ts b/src/http-client/http-client.ts index b9b0d7f..0e92719 100644 --- a/src/http-client/http-client.ts +++ b/src/http-client/http-client.ts @@ -268,16 +268,39 @@ export class HttpClient implements HttpClientInterface { ...'modelClass' in options.responseBody ? options.responseBody : {} } as BodyMetadata; - const responseBody: unknown = await this.parser.parseBody(res as unknown as HttpClientResponse, metadata); - this.validationService.validateBody(responseBody, metadata); + let responseBody: unknown; + try { + responseBody = await this.parser.parseBody(res as unknown as HttpClientResponse, metadata); + } + catch (error) { + throw new Error('Could not parse response body', { cause: error }); + } + + try { + this.validationService.validateBody(responseBody, metadata); + } + catch (error) { + throw new Error('Could not validate response body', { cause: error }); + } for (const key in options.responseHeaders) { const headerMetadata: HeaderParamMetadata = createHeaderParamMetadata(key, options.responseHeaders[key]); - (res.headers[key] as unknown) = this.parser.parseHeaderParam( - res as unknown as HttpClientResponse, - headerMetadata - ); - this.validationService.validateHeaderParam(res.headers[key], headerMetadata); + try { + (res.headers[key] as unknown) = this.parser.parseHeaderParam( + res as unknown as HttpClientResponse, + headerMetadata + ); + } + catch (error) { + throw new Error(`Could not parse response header "${headerMetadata.name}"`, { cause: error }); + } + + try { + this.validationService.validateHeaderParam(res.headers[key], headerMetadata); + } + catch (error) { + throw new Error(`Could not validate response header "${headerMetadata.name}"`, { cause: error }); + } } return { ...res, body: responseBody }; diff --git a/src/parsing/form-data/form-data.body-parser.ts b/src/parsing/form-data/form-data.body-parser.ts index 16b029c..c2728d0 100644 --- a/src/parsing/form-data/form-data.body-parser.ts +++ b/src/parsing/form-data/form-data.body-parser.ts @@ -17,8 +17,9 @@ import { BodyParserInterface } from '../body-parser.interface'; import { BodyParser } from '../decorators'; import { FormDataBodyParserCleanupCronJob } from './form-data-body-parser-cleanup.cron-job'; import { FormData, FormDataValue } from './form-data.model'; -import { PropertyMetadata } from '../../entity'; +import { PropertyMetadata, Relation } from '../../entity'; import { BigNumberUtilities, MetadataUtilities, UUIDUtilities } from '../../utilities'; +import { parseArray, parseBoolean, parseDate, parseNumber, parseObject, parseString } from '../functions'; // eslint-disable-next-line jsdoc/require-jsdoc type ParsedForm = { @@ -123,15 +124,49 @@ export class FormDataBodyParser implements BodyParserInterface { this.addStringValuesToMap(request, multiPartMap); this.addFilesToMap(request, multiPartMap, metadata); - const res: Partial> = {}; + const properties: Record = MetadataUtilities.getModelProperties(metadata.modelClass); + const res: Partial> = {}; for (const [key, value] of multiPartMap) { - try { - // eslint-disable-next-line typescript/no-unsafe-assignment - res[key] = JSON.parse(value as string); + if (typeof value !== 'string') { + res[key] = value; + continue; } - catch { - res[key] = value as T[keyof T]; - // throw new HttpErrors.BadRequest(`The provided form-data value "${String(key)}" is neither a file nor valid JSON.`); + + const propertyMetadata: PropertyMetadata = properties[key as string]; + switch (propertyMetadata.type) { + case 'string': { + res[key] = parseString(value); + break; + } + case 'number': { + res[key] = parseNumber(value); + break; + } + case 'boolean': { + res[key] = parseBoolean(value); + break; + } + case 'object': { + res[key] = parseObject(value, propertyMetadata.cls()); + break; + } + case 'array': { + res[key] = parseArray(value, propertyMetadata.items); + break; + } + case 'date': { + res[key] = parseDate(value); + break; + } + case Relation.ONE_TO_ONE: + case Relation.ONE_TO_MANY: + case Relation.MANY_TO_ONE: + case Relation.MANY_TO_MANY: + case 'file': + case 'unknown': { + res[key] = value; + break; + } } } return res as T; diff --git a/src/parsing/functions/parse-array.function.ts b/src/parsing/functions/parse-array.function.ts index 0bb4ac6..be5638c 100644 --- a/src/parsing/functions/parse-array.function.ts +++ b/src/parsing/functions/parse-array.function.ts @@ -1,16 +1,66 @@ -import { BadRequestError } from '../../error-handling'; -import { QueryParamMetadata } from '../../routing'; +import { parseBoolean } from './parse-boolean.function'; +import { parseDate } from './parse-date.function'; +import { parseNumber } from './parse-number.function'; +import { parseObject } from './parse-object.function'; +import { parseString } from './parse-string.function'; +import { ArrayPropertyItemMetadata } from '../../entity'; +import { ArrayParamItemMetadata } from '../../routing'; // eslint-disable-next-line jsdoc/require-jsdoc -export function parseArray(rawValue: unknown, meta: QueryParamMetadata): unknown { - if (rawValue == undefined || typeof rawValue !== 'string') { +export function parseArray( + rawValue: unknown, + itemMetadata: ArrayPropertyItemMetadata | ArrayParamItemMetadata +): unknown { + if (rawValue == undefined) { return rawValue; } + let simpleParsedValue: unknown = rawValue; try { - return JSON.parse(rawValue); + if (typeof rawValue === 'string') { + simpleParsedValue = JSON.parse(rawValue); + } } catch { - throw new BadRequestError(`invalid JSON in query param "${meta.name}"`); + return simpleParsedValue; } + + if (!Array.isArray(simpleParsedValue)) { + return simpleParsedValue; + } + + for (let i: number = 0; i < simpleParsedValue.length; i++) { + switch (itemMetadata.type) { + case 'string': { + simpleParsedValue[i] = parseString(simpleParsedValue[i]); + break; + } + case 'number': { + simpleParsedValue[i] = parseNumber(simpleParsedValue[i]); + break; + } + case 'boolean': { + simpleParsedValue[i] = parseBoolean(simpleParsedValue[i]); + break; + } + case 'object': { + simpleParsedValue[i] = parseObject(simpleParsedValue[i], itemMetadata.cls()); + break; + } + case 'array': { + simpleParsedValue[i] = parseArray(simpleParsedValue[i], itemMetadata.items); + break; + } + case 'date': { + simpleParsedValue[i] = parseDate(simpleParsedValue[i]); + break; + } + case 'file': + case 'unknown': { + break; + } + } + } + + return simpleParsedValue; } \ No newline at end of file diff --git a/src/parsing/functions/parse-array.test.ts b/src/parsing/functions/parse-array.test.ts index 15dbced..715fc86 100644 --- a/src/parsing/functions/parse-array.test.ts +++ b/src/parsing/functions/parse-array.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from '@jest/globals'; import { parseArray } from './parse-array.function'; -import { BadRequestError } from '../../error-handling'; import { ArrayParamMetadata } from '../../routing'; describe('parseArray', () => { @@ -42,17 +41,7 @@ describe('parseArray', () => { expect(parseArray('["a", "b"]', meta)).toEqual(['a', 'b']); }); - it('throws BadRequestError on invalid JSON', () => { - expect(() => parseArray('[invalid]', meta)).toThrow(BadRequestError); - }); - - it('uses correct error message', () => { - try { - parseArray('[', meta); - } - catch (error) { - expect(error).toBeInstanceOf(BadRequestError); - expect((error as Error).message).toBe('invalid JSON in query param "foo"'); - } + it('parses invalid json', () => { + expect(parseArray('[invalid]', meta)).toEqual('[invalid]'); }); }); \ No newline at end of file diff --git a/src/parsing/functions/parse-object.function.ts b/src/parsing/functions/parse-object.function.ts index c94d766..ac69211 100644 --- a/src/parsing/functions/parse-object.function.ts +++ b/src/parsing/functions/parse-object.function.ts @@ -1,16 +1,75 @@ -import { BadRequestError } from '../../error-handling'; -import { QueryParamMetadata } from '../../routing'; + +import { parseArray } from './parse-array.function'; +import { parseBoolean } from './parse-boolean.function'; +import { parseDate } from './parse-date.function'; +import { parseNumber } from './parse-number.function'; +import { parseString } from './parse-string.function'; +import { PropertyMetadata, Relation } from '../../entity'; +import { Newable } from '../../types'; +import { MetadataUtilities } from '../../utilities'; // eslint-disable-next-line jsdoc/require-jsdoc -export function parseObject(rawValue: unknown, meta: QueryParamMetadata): unknown { - if (rawValue == undefined || typeof rawValue !== 'string') { +export function parseObject( + rawValue: unknown, + cls: Newable +): unknown { + if (rawValue == undefined) { return rawValue; } + let simpleParsedValue: unknown = rawValue; try { - return JSON.parse(rawValue); + if (typeof rawValue === 'string') { + simpleParsedValue = JSON.parse(rawValue); + } } catch { - throw new BadRequestError(`invalid JSON in query param "${meta.name}"`); + return simpleParsedValue; + } + + if (typeof simpleParsedValue !== 'object' || simpleParsedValue === null) { + return simpleParsedValue; } + + const res: Record = simpleParsedValue as Record; + const properties: Record = MetadataUtilities.getModelProperties(cls); + + for (const [propertyKey, m] of Object.entries(properties)) { + switch (m.type) { + case 'string': { + res[propertyKey] = parseString(res[propertyKey]); + break; + } + case 'number': { + res[propertyKey] = parseNumber(res[propertyKey]); + break; + } + case 'boolean': { + res[propertyKey] = parseBoolean(res[propertyKey]); + break; + } + case 'object': { + res[propertyKey] = parseObject(res[propertyKey], m.cls()); + break; + } + case 'date': { + res[propertyKey] = parseDate(res[propertyKey]); + break; + } + case 'array': { + res[propertyKey] = parseArray(res[propertyKey], m); + break; + } + case Relation.ONE_TO_ONE: + case Relation.ONE_TO_MANY: + case Relation.MANY_TO_ONE: + case Relation.MANY_TO_MANY: + case 'file': + case 'unknown': { + break; + } + } + } + + return res; } \ No newline at end of file diff --git a/src/parsing/functions/parse-object.test.ts b/src/parsing/functions/parse-object.test.ts index 43d1f9d..dbbca22 100644 --- a/src/parsing/functions/parse-object.test.ts +++ b/src/parsing/functions/parse-object.test.ts @@ -2,70 +2,62 @@ import { describe, expect, it } from '@jest/globals'; import { parseObject } from './parse-object.function'; -import { BadRequestError } from '../../error-handling'; -import { ObjectParamMetadata } from '../../routing'; +import { Property } from '../../entity'; + +class Dummy {} + +class Dummy2 { + @Property.date() + testDate!: Date; +} describe('parseObject', () => { - const meta: ObjectParamMetadata = { - name: 'myObject', - type: 'object', - required: true, - description: undefined, - cls: () => class Dummy {}, - allowAdditionalProperties: false - }; + it('parses date correctly', () => { + const parsedObject: { testDate: Date } = parseObject(JSON.stringify({ testDate: new Date() }), Dummy2) as { testDate: Date }; + expect(parsedObject.testDate).toBeInstanceOf(Date); + }); it('returns undefined for undefined input', () => { - expect(parseObject(undefined, meta)).toBeUndefined(); + expect(parseObject(undefined, Dummy)).toBeUndefined(); }); it('returns null for null input', () => { - expect(parseObject(null, meta)).toBeNull(); + expect(parseObject(null, Dummy)).toBeNull(); }); it('returns input unchanged if not a string', () => { const input: object = { foo: 'bar' }; - expect(parseObject(input, meta)).toBe(input); + expect(parseObject(input, Dummy)).toBe(input); }); it('parses valid JSON object string', () => { const json: string = '{"a": 1, "b": "test"}'; - expect(parseObject(json, meta)).toEqual({ a: 1, b: 'test' }); + expect(parseObject(json, Dummy)).toEqual({ a: 1, b: 'test' }); }); it('parses valid nested object', () => { const json: string = '{"x": {"y": [1, 2, 3]}}'; - expect(parseObject(json, meta)).toEqual({ x: { y: [1, 2, 3] } }); + expect(parseObject(json, Dummy)).toEqual({ x: { y: [1, 2, 3] } }); }); it('parses empty JSON object', () => { - expect(parseObject('{}', meta)).toEqual({}); - }); - - it('throws BadRequestError for invalid JSON', () => { - expect(() => parseObject('{invalid}', meta)).toThrow(BadRequestError); + expect(parseObject('{}', Dummy)).toEqual({}); }); - it('throws BadRequestError with correct message', () => { - try { - parseObject('[', meta); - } - catch (error) { - expect(error).toBeInstanceOf(BadRequestError); - expect((error as Error).message).toBe('invalid JSON in query param "myObject"'); - } + it('parses invalid json', () => { + expect(parseObject('{invalid}', Dummy)).toEqual('{invalid}'); }); it('parses valid array JSON string (edge case)', () => { // Should still parse since it's valid JSON even if semantically not an object - expect(parseObject('[1,2]', meta)).toEqual([1, 2]); + expect(parseObject('[1,2]', Dummy)).toEqual([1, 2]); }); it('returns input unchanged for boolean true', () => { - expect(parseObject(true, meta)).toBe(true); + expect(parseObject(true, Dummy)).toBe(true); }); it('returns input unchanged for numeric input', () => { - expect(parseObject(42, meta)).toBe(42); + expect(parseObject(42, Dummy)).toBe(42); }); }); \ No newline at end of file diff --git a/src/parsing/json/json.body-parser.ts b/src/parsing/json/json.body-parser.ts index 6b6e87f..3a5d049 100644 --- a/src/parsing/json/json.body-parser.ts +++ b/src/parsing/json/json.body-parser.ts @@ -6,6 +6,7 @@ import { BigNumber, BigNumberUtilities } from '../../utilities'; import { WebsocketRequest } from '../../websocket'; import { BodyParserInterface } from '../body-parser.interface'; import { BodyParser } from '../decorators'; +import { parseArray, parseObject } from '../functions'; /** * Body parser for json. @@ -16,13 +17,40 @@ export class JsonBodyParser implements BodyParserInterface { readonly contentType: MimeType = MimeType.JSON; // eslint-disable-next-line jsdoc/require-jsdoc - parseFromHttpClientResponse(res: HttpClientResponse): unknown { - return res.rawBody; + parseFromHttpClientResponse(res: HttpClientResponse, metadata: BodyMetadata): unknown { + if (res.body !== undefined) { + return res.body; + } + if (!metadata.isArray) { + return parseObject(res.rawBody, metadata.modelClass); + } + return parseArray( + res.rawBody, + { + type: 'object', + cls: () => metadata.modelClass, + allowAdditionalProperties: metadata.allowAdditionalProperties, + description: metadata.description, + required: metadata.required + } + ); } // eslint-disable-next-line jsdoc/require-jsdoc - parseFromWebsocketRequest(req: WebsocketRequest): unknown { - return req.body; + parseFromWebsocketRequest(req: WebsocketRequest, metadata: BodyMetadata): unknown { + if (!metadata.isArray) { + return parseObject(req.body, metadata.modelClass); + } + return parseArray( + req.body, + { + type: 'object', + cls: () => metadata.modelClass, + allowAdditionalProperties: metadata.allowAdditionalProperties, + description: metadata.description, + required: metadata.required + } + ); } // eslint-disable-next-line jsdoc/require-jsdoc @@ -80,7 +108,23 @@ export class JsonBodyParser implements BodyParserInterface { try { const raw: Buffer = Buffer.concat(chunks); - return raw.length > 0 ? JSON.parse(raw.toString('utf8')) : undefined; + if (!raw.length) { + return undefined; + } + + if (!metadata.isArray) { + return parseObject(raw.toString('utf8'), metadata.modelClass); + } + return parseArray( + raw.toString('utf8'), + { + type: 'object', + cls: () => metadata.modelClass, + allowAdditionalProperties: metadata.allowAdditionalProperties, + description: metadata.description, + required: metadata.required + } + ); } catch { throw new BadRequestError('invalid JSON in request body'); diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index b6f00fc..36db7d7 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -1,3 +1,5 @@ +import assert from 'assert'; + import { inject, ZIBRI_DI_TOKENS } from '../di'; import { GlobalRegistry } from '../global'; import { BodyParserInterface } from './body-parser.interface'; @@ -44,8 +46,14 @@ export class Parser implements ParserInterface { number: parseNumber, boolean: parseBoolean, date: parseDate, - object: parseObject, - array: parseArray + object: (rawValue, meta) => { + assert(meta.type === 'object'); + return parseObject(rawValue, meta.cls()); + }, + array: (rawValue, meta) => { + assert(meta.type === 'array'); + return parseArray(rawValue, meta); + } }; private readonly headerParamParseFunctions: Record = { @@ -53,8 +61,14 @@ export class Parser implements ParserInterface { number: parseNumber, boolean: parseBoolean, date: parseDate, - object: parseObject, - array: parseArray + object: (rawValue, meta) => { + assert(meta.type === 'object'); + return parseObject(rawValue, meta.cls()); + }, + array: (rawValue, meta) => { + assert(meta.type === 'array'); + return parseArray(rawValue, meta); + } }; constructor() { diff --git a/src/routing/decorators/body.decorator.ts b/src/routing/decorators/body.decorator.ts index 98dac69..4e23cba 100644 --- a/src/routing/decorators/body.decorator.ts +++ b/src/routing/decorators/body.decorator.ts @@ -132,10 +132,10 @@ function resolveMaxSize(bytes: BigNumber, properties: Record