Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,41 +52,42 @@ export async function generateEntityFilesForProvider(
): Promise<GenerateEntityFilesForProviderResult> {
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);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenApiDefinition> {
return this.spec;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
7 changes: 6 additions & 1 deletion src/entity/generation/providers/open-api-file.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenApiDefinition> {
Expand Down
7 changes: 6 additions & 1 deletion src/entity/generation/providers/open-api-url.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenApiDefinition> {
Expand Down
37 changes: 30 additions & 7 deletions src/http-client/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
51 changes: 43 additions & 8 deletions src/parsing/form-data/form-data.body-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -123,15 +124,49 @@ export class FormDataBodyParser implements BodyParserInterface {
this.addStringValuesToMap(request, multiPartMap);
this.addFilesToMap<T>(request, multiPartMap, metadata);

const res: Partial<Record<keyof T, T[keyof T]>> = {};
const properties: Record<string, PropertyMetadata> = MetadataUtilities.getModelProperties(metadata.modelClass);
const res: Partial<Record<keyof T, unknown>> = {};
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;
Expand Down
62 changes: 56 additions & 6 deletions src/parsing/functions/parse-array.function.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 2 additions & 13 deletions src/parsing/functions/parse-array.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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]');
});
});
Loading
Loading