diff --git a/package.json b/package.json index 1dd214bf..67240c28 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "koa-compose": "^4.1.0", "lodash": "^4.17.21", "nunjucks": "^3.2.3", + "openapi3-ts": "^2.0.2", "reflect-metadata": "^0.1.13", "tslib": "^2.3.0" }, diff --git a/packages/build/src/lib/schema-parser/middleware/addRequiredValidatorForPath.ts b/packages/build/src/lib/schema-parser/middleware/addRequiredValidatorForPath.ts new file mode 100644 index 00000000..0c794905 --- /dev/null +++ b/packages/build/src/lib/schema-parser/middleware/addRequiredValidatorForPath.ts @@ -0,0 +1,23 @@ +import { FieldInType } from '@vulcan/core'; +import { SchemaParserMiddleware } from './middleware'; + +// Add the "required" validator when the parameters are in path +export const addRequiredValidatorForPath = + (): SchemaParserMiddleware => async (schemas, next) => { + await next(); + const requests = schemas.request || []; + for (const request of requests) { + if (request.fieldIn !== FieldInType.PATH) continue; + if (!request.validators) request.validators = []; + if ( + !request.validators?.some( + (validator) => (validator as any).name === 'required' + ) + ) { + request.validators?.push({ + name: 'required', + args: {}, + }); + } + } + }; diff --git a/packages/build/src/lib/schema-parser/middleware/generateDataType.ts b/packages/build/src/lib/schema-parser/middleware/generateDataType.ts new file mode 100644 index 00000000..f027fe38 --- /dev/null +++ b/packages/build/src/lib/schema-parser/middleware/generateDataType.ts @@ -0,0 +1,30 @@ +import { FieldDataType } from '@vulcan/core'; +import { DeepPartial } from 'ts-essentials'; +import { RawResponseProperty, SchemaParserMiddleware } from './middleware'; + +const generateResponsePropertyType = ( + property: DeepPartial +) => { + if (!property.type) { + property.type = FieldDataType.STRING; + } else if (Array.isArray(property.type)) { + ((property.type as DeepPartial[]) || []).forEach( + (property) => generateResponsePropertyType(property) + ); + } +}; + +// Fallback to string when type is not defined. +// TODO: Guess the type by validators. +export const generateDataType = + (): SchemaParserMiddleware => async (schemas, next) => { + await next(); + (schemas.request || []).forEach((request) => { + if (!request.type) { + request.type = FieldDataType.STRING; + } + }); + (schemas.response || []).forEach((property) => + generateResponsePropertyType(property) + ); + }; diff --git a/packages/build/src/lib/schema-parser/middleware/generatePathParameters.ts b/packages/build/src/lib/schema-parser/middleware/generatePathParameters.ts new file mode 100644 index 00000000..a9202405 --- /dev/null +++ b/packages/build/src/lib/schema-parser/middleware/generatePathParameters.ts @@ -0,0 +1,34 @@ +import { FieldDataType, FieldInType } from '@vulcan/core'; +import { SchemaParserMiddleware } from './middleware'; + +// /user/{id} => {request: [{fieldName: 'id', fieldIn: 'path' ....}]} +export const generatePathParameters = + (): SchemaParserMiddleware => async (schema, next) => { + await next(); + const pattern = /:([^/]+)/g; + const pathParameters: string[] = []; + + let param = pattern.exec(schema.urlPath || ''); + while (param) { + pathParameters.push(param[1]); + param = pattern.exec(schema.urlPath || ''); + } + + const request = schema.request || []; + pathParameters + .filter((param) => !request.some((req) => req.fieldName === param)) + .forEach((param) => + request.push({ + fieldName: param, + fieldIn: FieldInType.PATH, + type: FieldDataType.STRING, + validators: [ + { + name: 'required', + args: {}, + }, + ], + }) + ); + schema.request = request; + }; diff --git a/packages/build/src/lib/schema-parser/middleware/index.ts b/packages/build/src/lib/schema-parser/middleware/index.ts index 6ac7e2d3..ca344e7b 100644 --- a/packages/build/src/lib/schema-parser/middleware/index.ts +++ b/packages/build/src/lib/schema-parser/middleware/index.ts @@ -6,3 +6,9 @@ export * from './generateTemplateSource'; export * from './checkParameter'; export * from './addMissingErrors'; export * from './fallbackErrors'; +export * from './normalizeFieldIn'; +export * from './generateDataType'; +export * from './normalizeDataType'; +export * from './generatePathParameters'; +export * from './addRequiredValidatorForPath'; +export * from './setConstraints'; diff --git a/packages/build/src/lib/schema-parser/middleware/middleware.ts b/packages/build/src/lib/schema-parser/middleware/middleware.ts index 08468dcb..1cd274ec 100644 --- a/packages/build/src/lib/schema-parser/middleware/middleware.ts +++ b/packages/build/src/lib/schema-parser/middleware/middleware.ts @@ -1,4 +1,10 @@ -import { APISchema, RequestParameter, ValidatorDefinition } from '@vulcan/core'; +import { + APISchema, + FieldDataType, + RequestParameter, + ResponseProperty, + ValidatorDefinition, +} from '@vulcan/core'; import { DeepPartial } from 'ts-essentials'; export interface RawRequestParameter @@ -6,10 +12,16 @@ export interface RawRequestParameter validators: Array; } -export interface RawAPISchema extends DeepPartial> { +export interface RawResponseProperty extends Omit { + type: string | FieldDataType | Array; +} + +export interface RawAPISchema + extends DeepPartial> { /** Indicate the identifier of this schema from the source, it might be uuid, file path, url ...etc, depend on the provider */ sourceName: string; request?: DeepPartial; + response?: DeepPartial; } export interface SchemaParserMiddleware { diff --git a/packages/build/src/lib/schema-parser/middleware/normalizeDataType.ts b/packages/build/src/lib/schema-parser/middleware/normalizeDataType.ts new file mode 100644 index 00000000..08e9f349 --- /dev/null +++ b/packages/build/src/lib/schema-parser/middleware/normalizeDataType.ts @@ -0,0 +1,32 @@ +import { FieldDataType } from '@vulcan/core'; +import { DeepPartial } from 'ts-essentials'; +import { RawResponseProperty, SchemaParserMiddleware } from './middleware'; + +const normalizedResponsePropertyType = ( + property: DeepPartial +) => { + if (typeof property.type === 'string') { + property.type = property.type.toUpperCase() as FieldDataType; + } else { + ((property.type as DeepPartial[]) || []).forEach( + (property) => normalizedResponsePropertyType(property) + ); + } +}; + +// type: string => FieldIn FieldDataType.STRING +export const normalizeDataType = + (): SchemaParserMiddleware => async (schemas, next) => { + // Request + (schemas.request || []).forEach((request) => { + if (request.type) { + request.type = request.type.toUpperCase() as FieldDataType; + } + }); + // Response + (schemas.response || []).forEach((property) => + normalizedResponsePropertyType(property) + ); + + return next(); + }; diff --git a/packages/build/src/lib/schema-parser/middleware/normalizeFieldIn.ts b/packages/build/src/lib/schema-parser/middleware/normalizeFieldIn.ts new file mode 100644 index 00000000..e34d9def --- /dev/null +++ b/packages/build/src/lib/schema-parser/middleware/normalizeFieldIn.ts @@ -0,0 +1,13 @@ +import { FieldInType } from '@vulcan/core'; +import { SchemaParserMiddleware } from './middleware'; + +// FieldIn: query => FieldIn FieldInType.QUERY +export const normalizeFieldIn = + (): SchemaParserMiddleware => async (schemas, next) => { + (schemas.request || []).forEach((request) => { + if (request.fieldIn) { + request.fieldIn = request.fieldIn.toUpperCase() as FieldInType; + } + }); + return next(); + }; diff --git a/packages/build/src/lib/schema-parser/middleware/setConstraints.ts b/packages/build/src/lib/schema-parser/middleware/setConstraints.ts new file mode 100644 index 00000000..42a9b8fc --- /dev/null +++ b/packages/build/src/lib/schema-parser/middleware/setConstraints.ts @@ -0,0 +1,36 @@ +import { APISchema, ValidatorLoader } from '@vulcan/core'; +import { chain } from 'lodash'; +import { SchemaParserMiddleware } from './middleware'; + +export const setConstraints = + (loader: ValidatorLoader): SchemaParserMiddleware => + async (rawSchema, next) => { + await next(); + const schema = rawSchema as APISchema; + for (const request of schema.request || []) { + request.constraints = chain(request.validators || []) + .map((validator) => ({ + validator: loader.getLoader(validator.name), + args: validator.args, + })) + .filter(({ validator }) => !!validator.getConstraints) + .flatMap(({ validator, args }) => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + validator.getConstraints!(args) + ) + // Group by constraint class (RequiredConstraint, MinValueConstraint ....) + .groupBy((constraint) => constraint.constructor.name) + .values() + .map((constraints) => { + let mergeConstraint = constraints[0]; + constraints + .slice(1) + .forEach( + (constraint) => + (mergeConstraint = mergeConstraint.compose(constraint)) + ); + return mergeConstraint; + }) + .value(); + } + }; diff --git a/packages/build/src/lib/schema-parser/schemaParser.ts b/packages/build/src/lib/schema-parser/schemaParser.ts index 4cc6ccd3..3ad40f46 100644 --- a/packages/build/src/lib/schema-parser/schemaParser.ts +++ b/packages/build/src/lib/schema-parser/schemaParser.ts @@ -16,6 +16,12 @@ import { checkParameter, fallbackErrors, addMissingErrors, + normalizeFieldIn, + generateDataType, + normalizeDataType, + generatePathParameters, + addRequiredValidatorForPath, + setConstraints, } from './middleware'; import * as compose from 'koa-compose'; import { inject, injectable, interfaces } from 'inversify'; @@ -45,6 +51,12 @@ export class SchemaParser { this.use(transformValidator()); this.use(checkValidator(validatorLoader)); this.use(fallbackErrors()); + this.use(normalizeFieldIn()); + this.use(generateDataType()); + this.use(normalizeDataType()); + this.use(generatePathParameters()); + this.use(addRequiredValidatorForPath()); + this.use(setConstraints(validatorLoader)); } public async parse({ diff --git a/packages/build/src/lib/spec-generator/index.ts b/packages/build/src/lib/spec-generator/index.ts new file mode 100644 index 00000000..d5a1984b --- /dev/null +++ b/packages/build/src/lib/spec-generator/index.ts @@ -0,0 +1 @@ +export * from './oas3'; diff --git a/packages/build/src/lib/spec-generator/oas3/index.ts b/packages/build/src/lib/spec-generator/oas3/index.ts new file mode 100644 index 00000000..762bcd05 --- /dev/null +++ b/packages/build/src/lib/spec-generator/oas3/index.ts @@ -0,0 +1 @@ +export * from './oas3SpecGenerator'; diff --git a/packages/build/src/lib/spec-generator/oas3/oas3SpecGenerator.ts b/packages/build/src/lib/spec-generator/oas3/oas3SpecGenerator.ts new file mode 100644 index 00000000..20fa80fd --- /dev/null +++ b/packages/build/src/lib/spec-generator/oas3/oas3SpecGenerator.ts @@ -0,0 +1,257 @@ +import { SpecGenerator } from '../specGenerator'; +import * as oas3 from 'openapi3-ts'; +import { + APISchema, + EnumConstraint, + ErrorInfo, + FieldDataType, + FieldInType, + MaxLengthConstraint, + MaxValueConstraint, + MinLengthConstraint, + MinValueConstraint, + RegexConstraint, + RequestParameter, + RequiredConstraint, + ResponseProperty, +} from '@vulcan/core'; +import { isEmpty } from 'lodash'; + +export class OAS3SpecGenerator extends SpecGenerator { + // Follow the OpenAPI specification version 3.0.3 + // see https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md + private oaiVersion = '3.0.3'; + + public getSpec() { + const spec: oas3.OpenAPIObject = { + openapi: this.getOAIVersion(), + info: this.getInfo(), + paths: this.getPaths(), + }; + return spec; + } + + public getOAIVersion() { + return this.oaiVersion; + } + + private getInfo(): oas3.InfoObject { + return { + title: this.getName(), + version: this.getVersion(), + description: this.getDescription(), + }; + } + + private getPaths(): oas3.PathsObject { + const paths: oas3.PathsObject = {}; + const schemas = this.getSchemas(); + for (const schema of schemas) { + paths[this.convertToOASPath(schema.urlPath)] = this.getPath(schema); + } + return paths; + } + + private getPath(schema: APISchema): oas3.PathItemObject { + return { + get: this.getOperationObject(schema), + }; + } + + // "/user/:id" -> "/user/{id}" + private convertToOASPath(path: string) { + return path.replace(/:([^/]+)/g, (_, param) => { + return `{${param}}`; + }); + } + + private getOperationObject(schema: APISchema): oas3.OperationObject { + return { + description: schema.description, + responses: this.getResponsesObject(schema), + parameters: this.getParameterObject(schema), + }; + } + + private getParameterObject(schema: APISchema): oas3.ParameterObject[] { + const parameters: oas3.ParameterObject[] = []; + const requests = schema.request || []; + + for (const param of requests) { + parameters.push({ + name: param.fieldName, + in: this.convertFieldInTypeToOASIn(param.fieldIn), + schema: this.getSchemaObjectFromParameter(param), + required: this.isParameterRequired(param), + }); + } + + return parameters; + } + + private convertFieldInTypeToOASIn( + fieldInType: FieldInType + ): oas3.ParameterLocation { + switch (fieldInType) { + case FieldInType.HEADER: + return 'header'; + case FieldInType.PATH: + return 'path'; + case FieldInType.QUERY: + return 'query'; + default: + throw new Error(`FieldInType ${fieldInType} is not supported`); + } + } + + private getSchemaObjectFromParameter( + parameter: RequestParameter + ): oas3.SchemaObject { + const schema: oas3.SchemaObject = { + type: this.convertFieldDataTypeToOASType(parameter.type), + }; + + for (const constraint of parameter.constraints) { + if (constraint instanceof MinValueConstraint) { + schema.minimum = constraint.getMinValue(); + } else if (constraint instanceof MaxValueConstraint) { + schema.maximum = constraint.getMaxValue(); + } else if (constraint instanceof MinLengthConstraint) { + schema.minLength = constraint.getMinLength(); + } else if (constraint instanceof MaxLengthConstraint) { + schema.maxLength = constraint.getMaxLength(); + } else if (constraint instanceof RegexConstraint) { + schema.pattern = constraint.getRegex(); + } else if (constraint instanceof EnumConstraint) { + schema.enum = constraint.getList(); + } + } + + return schema; + } + + private convertFieldDataTypeToOASType( + fieldDataType: FieldDataType + ): oas3.SchemaObject['type'] { + switch (fieldDataType) { + case FieldDataType.BOOLEAN: + return 'boolean'; + case FieldDataType.NUMBER: + return 'number'; + case FieldDataType.STRING: + return 'string'; + default: + throw new Error(`FieldDataType ${fieldDataType} is not supported`); + } + } + + private isParameterRequired(parameter: RequestParameter) { + return parameter.constraints.some( + (constraint) => constraint instanceof RequiredConstraint + ); + } + + private getResponsesObject(schema: APISchema): oas3.ResponsesObject { + const clientError = { + description: 'Client error', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + }, + }, + examples: this.getErrorCodes(schema.errors || []), + }, + }, + }; + return { + '200': { + description: 'The default response', + content: { + 'application/json': { + schema: this.getSchemaObjectFroResponseProperties( + schema.response || [] + ), + }, + 'text/csv': { + schema: { + type: 'string', + }, + }, + }, + }, + '400': isEmpty(clientError.content['application/json'].examples) + ? undefined + : clientError, + '5XX': { + description: 'Server error', + }, + }; + } + + private getSchemaObjectFroResponseProperties( + responseProperties: ResponseProperty[] + ): oas3.SchemaObject { + const required = responseProperties + .filter((property) => property.required) + .map((property) => property.name); + const properties: Record = {}; + for (const property of responseProperties) { + properties[property.name] = + this.getSchemaObjectFroResponseProperty(property); + } + return { + type: 'object', + properties, + required: required.length === 0 ? undefined : required, + }; + } + + private getSchemaObjectFroResponseProperty( + responseProperty: ResponseProperty + ): oas3.SchemaObject { + const basicContent = { + description: responseProperty.description, + }; + if (responseProperty.type === FieldDataType.BOOLEAN) { + return { + ...basicContent, + type: 'boolean', + }; + } else if (responseProperty.type === FieldDataType.STRING) { + return { + ...basicContent, + type: 'string', + }; + } else if (responseProperty.type === FieldDataType.NUMBER) { + return { + ...basicContent, + type: 'number', + }; + } else { + return { + ...basicContent, + ...this.getSchemaObjectFroResponseProperties( + responseProperty.type || [] + ), + }; + } + } + + private getErrorCodes(errorInfos: ErrorInfo[]): oas3.ExamplesObject { + const examples: oas3.ExamplesObject = {}; + for (const errorInfo of errorInfos) { + examples[errorInfo.code] = { + description: errorInfo.message, + value: { + code: errorInfo.code, + message: errorInfo.message, + }, + }; + } + return examples; + } +} diff --git a/packages/build/src/lib/spec-generator/specGenerator.ts b/packages/build/src/lib/spec-generator/specGenerator.ts new file mode 100644 index 00000000..9268121b --- /dev/null +++ b/packages/build/src/lib/spec-generator/specGenerator.ts @@ -0,0 +1,30 @@ +import { APISchema } from '@vulcan/core'; +import { IBuildOptions } from '../../models/buildOptions'; + +export abstract class SpecGenerator { + private schemas: APISchema[]; + private config: IBuildOptions; + + constructor(schemas: APISchema[], config: IBuildOptions) { + this.schemas = schemas; + this.config = config; + } + + abstract getSpec(): T; + + protected getName() { + return this.config.name || 'API Server'; + } + + protected getDescription() { + return this.config.description; + } + + protected getVersion() { + return this.config.version || '0.0.1'; + } + + protected getSchemas() { + return this.schemas; + } +} diff --git a/packages/build/src/models/buildOptions.ts b/packages/build/src/models/buildOptions.ts index 745c551e..d6646eb8 100644 --- a/packages/build/src/models/buildOptions.ts +++ b/packages/build/src/models/buildOptions.ts @@ -2,5 +2,8 @@ import { ICoreOptions } from '@vulcan/core'; import { ISchemaParserOptions } from './schemaParserOptions'; export interface IBuildOptions extends ICoreOptions { + name?: string; + description?: string; + version?: string; schemaParser: ISchemaParserOptions; } diff --git a/packages/build/test/schema-parser/middleware/addRequiredValidatorForPath.spec.ts b/packages/build/test/schema-parser/middleware/addRequiredValidatorForPath.spec.ts new file mode 100644 index 00000000..b4261921 --- /dev/null +++ b/packages/build/test/schema-parser/middleware/addRequiredValidatorForPath.spec.ts @@ -0,0 +1,61 @@ +import { RawAPISchema } from '@vulcan/build/schema-parser'; +import { addRequiredValidatorForPath } from '@vulcan/build/schema-parser/middleware'; +import { APISchema, FieldInType } from '@vulcan/core'; + +it('Should add required validator for parameter in path', async () => { + // Arrange + const schema: RawAPISchema = { + templateSource: 'existed/path', + sourceName: 'some-name', + urlPath: '/:id/:oid', + request: [ + { + fieldName: 'id', + fieldIn: FieldInType.PATH, + }, + { + fieldName: 'oid', + fieldIn: FieldInType.PATH, + validators: [{ name: 'some-validator' }], + }, + ], + }; + // Act + await addRequiredValidatorForPath()(schema, async () => Promise.resolve()); + // Assert + expect((schema as APISchema).request?.[0].validators?.[0].name).toEqual( + 'required' + ); + expect((schema as APISchema).request?.[1].validators?.[1].name).toEqual( + 'required' + ); +}); + +it('Should not change the validator if it had already defined', async () => { + // Arrange + const schema: RawAPISchema = { + templateSource: 'existed/path', + sourceName: 'some-name', + urlPath: '/:id', + request: [ + { + fieldName: 'id', + fieldIn: FieldInType.PATH, + validators: [ + { + name: 'required', + args: { + foo: 'bar', + }, + }, + ], + }, + ], + }; + // Act + await addRequiredValidatorForPath()(schema, async () => Promise.resolve()); + // Assert + expect((schema as APISchema).request?.[0].validators?.[0].args.foo).toEqual( + 'bar' + ); +}); diff --git a/packages/build/test/schema-parser/middleware/generateDataType.spec.ts b/packages/build/test/schema-parser/middleware/generateDataType.spec.ts new file mode 100644 index 00000000..ad1f9794 --- /dev/null +++ b/packages/build/test/schema-parser/middleware/generateDataType.spec.ts @@ -0,0 +1,33 @@ +import { RawAPISchema } from '@vulcan/build/schema-parser'; +import { generateDataType } from '@vulcan/build/schema-parser/middleware'; +import { FieldDataType } from '@vulcan/core'; + +it('Should generate data type (string) for requests when it was not defined', async () => { + // Arrange + const schema: RawAPISchema = { + templateSource: 'existed/path', + sourceName: 'some-name', + request: [{}], + }; + // Act + await generateDataType()(schema, async () => Promise.resolve()); + // Assert + expect(schema.request?.[0].type).toEqual(FieldDataType.STRING); +}); + +it('Should generate data type (string) for responses when it was not defined', async () => { + // Arrange + const schema: RawAPISchema = { + templateSource: 'existed/path', + sourceName: 'some-name', + request: [], + response: [{}, { type: [{}] as any }], + }; + // Act + await generateDataType()(schema, async () => Promise.resolve()); + // Assert + expect(schema.response?.[0].type).toEqual(FieldDataType.STRING); + expect((schema.response?.[1].type?.[0] as any).type).toEqual( + FieldDataType.STRING + ); +}); diff --git a/packages/build/test/schema-parser/middleware/generatePathParameters.spec.ts b/packages/build/test/schema-parser/middleware/generatePathParameters.spec.ts new file mode 100644 index 00000000..94f8d498 --- /dev/null +++ b/packages/build/test/schema-parser/middleware/generatePathParameters.spec.ts @@ -0,0 +1,42 @@ +import { RawAPISchema } from '@vulcan/build/schema-parser/.'; +import { generatePathParameters } from '@vulcan/build/schema-parser/middleware'; +import { FieldDataType, FieldInType } from '@vulcan/core'; + +it('Should generate path parameters when they were not defined', async () => { + // Arrange + const schema: RawAPISchema = { + sourceName: 'some-source', + urlPath: 'existed/path/:id/order/:oid', + request: [], + }; + // Act + await generatePathParameters()(schema, async () => Promise.resolve()); + // Assert + expect(schema.request?.[0].fieldName).toEqual('id'); + expect(schema.request?.[0].type).toEqual(FieldDataType.STRING); + expect(schema.request?.[0].fieldIn).toEqual(FieldInType.PATH); + + expect(schema.request?.[1].fieldName).toEqual('oid'); + expect(schema.request?.[1].type).toEqual(FieldDataType.STRING); + expect(schema.request?.[1].fieldIn).toEqual(FieldInType.PATH); +}); + +it('Should keep original parameters when they had been defined', async () => { + // Arrange + const schema: RawAPISchema = { + sourceName: 'some-source', + urlPath: 'existed/path/:id', + request: [ + { + fieldName: 'id', + fieldIn: FieldInType.PATH, + type: FieldDataType.NUMBER, + }, + ], + }; + // Act + await generatePathParameters()(schema, async () => Promise.resolve()); + // Assert + expect(schema.request?.[0].fieldName).toEqual('id'); + expect(schema.request?.[0].type).toEqual(FieldDataType.NUMBER); +}); diff --git a/packages/build/test/schema-parser/middleware/normalizeDataType.spec.ts b/packages/build/test/schema-parser/middleware/normalizeDataType.spec.ts new file mode 100644 index 00000000..3855e5ed --- /dev/null +++ b/packages/build/test/schema-parser/middleware/normalizeDataType.spec.ts @@ -0,0 +1,47 @@ +import { RawAPISchema } from '@vulcan/build/schema-parser'; +import { normalizeDataType } from '@vulcan/build/schema-parser/middleware'; +import { FieldDataType } from '@vulcan/core'; + +it('Should normalize data type for requests', async () => { + // Arrange + const schema: RawAPISchema = { + templateSource: 'existed/path', + sourceName: 'some-name', + request: [ + { + type: 'nUmBeR' as any, + }, + ], + }; + // Act + await normalizeDataType()(schema, async () => Promise.resolve()); + // Assert + expect(schema.request?.[0].type).toEqual(FieldDataType.NUMBER); +}); + +it('Should normalize data type for responses', async () => { + // Arrange + const schema: RawAPISchema = { + templateSource: 'existed/path', + sourceName: 'some-name', + response: [ + { + type: 'nUmBeR' as any, + }, + { + type: [ + { + type: 'bOoLeAn', + }, + ], + }, + ], + }; + // Act + await normalizeDataType()(schema, async () => Promise.resolve()); + // Assert + expect(schema.response?.[0].type).toEqual(FieldDataType.NUMBER); + expect((schema.response?.[1] as any).type[0].type).toEqual( + FieldDataType.BOOLEAN + ); +}); diff --git a/packages/build/test/schema-parser/middleware/normalizeInField.spec.ts b/packages/build/test/schema-parser/middleware/normalizeInField.spec.ts new file mode 100644 index 00000000..35f3f4a5 --- /dev/null +++ b/packages/build/test/schema-parser/middleware/normalizeInField.spec.ts @@ -0,0 +1,20 @@ +import { RawAPISchema } from '@vulcan/build/schema-parser'; +import { normalizeFieldIn } from '@vulcan/build/schema-parser/middleware'; +import { FieldInType } from '@vulcan/core'; + +it('Should normalize in field', async () => { + // Arrange + const schema: RawAPISchema = { + templateSource: 'existed/path', + sourceName: 'some-name', + request: [ + { + fieldIn: 'qUerY' as any, + }, + ], + }; + // Act + await normalizeFieldIn()(schema, async () => Promise.resolve()); + // Assert + expect(schema.request?.[0].fieldIn).toEqual(FieldInType.QUERY); +}); diff --git a/packages/build/test/schema-parser/middleware/setConstraints.spec.ts b/packages/build/test/schema-parser/middleware/setConstraints.spec.ts new file mode 100644 index 00000000..6be0577a --- /dev/null +++ b/packages/build/test/schema-parser/middleware/setConstraints.spec.ts @@ -0,0 +1,52 @@ +import { RawAPISchema } from '@vulcan/build/schema-parser'; +import { setConstraints } from '@vulcan/build/schema-parser/middleware'; +import { + Constraint, + MinValueConstraint, + RequiredConstraint, + ValidatorLoader, +} from '@vulcan/core'; +import * as sinon from 'ts-sinon'; + +it('Should set and compose constraints', async () => { + // Arrange + const stubValidatorLoader = sinon.stubInterface(); + stubValidatorLoader.getLoader.callsFake((name) => ({ + name, + validateData: () => true, + validateSchema: () => true, + getConstraints: (args) => { + if (name === 'required') return [Constraint.Required()]; + return [Constraint.MinValue(args.value)]; + }, + })); + + const schema: RawAPISchema = { + templateSource: 'existed/path', + sourceName: 'some-name', + request: [ + { + validators: [ + { name: 'required', args: {} }, + { name: 'minValue', args: { value: 3 } }, + { name: 'minValue', args: { value: 1000 } }, + ], + }, + ], + }; + // Act + await setConstraints(stubValidatorLoader)(schema, async () => + Promise.resolve() + ); + // Assert + expect(schema.request?.[0].constraints?.length).toEqual(2); + expect( + schema.request?.[0].constraints?.[0] instanceof RequiredConstraint + ).toBeTruthy(); + expect( + schema.request?.[0].constraints?.[1] instanceof MinValueConstraint + ).toBeTruthy(); + expect( + (schema.request?.[0].constraints?.[1] as MinValueConstraint).getMinValue() + ).toBe(1000); +}); diff --git a/packages/build/test/spec-generator/.gitignore b/packages/build/test/spec-generator/.gitignore new file mode 100644 index 00000000..31c4496b --- /dev/null +++ b/packages/build/test/spec-generator/.gitignore @@ -0,0 +1 @@ +oas3-spec.yaml \ No newline at end of file diff --git a/packages/build/test/spec-generator/oas3-errors.spec.ts b/packages/build/test/spec-generator/oas3-errors.spec.ts new file mode 100644 index 00000000..a441da4a --- /dev/null +++ b/packages/build/test/spec-generator/oas3-errors.spec.ts @@ -0,0 +1,46 @@ +import { OAS3SpecGenerator } from '@vulcan/build/spec-generator'; + +it('Should throw error with invalid fieldIn', async () => { + // Arrange + const generator = new OAS3SpecGenerator( + [ + { + urlPath: '/user', + request: [ + { + name: 'id', + fieldIn: 'INVALID_VALUE', + }, + ], + } as any, + ], + {} as any + ); + // Act, Arrange + expect(() => generator.getSpec()).toThrow( + `FieldInType INVALID_VALUE is not supported` + ); +}); + +it('Should throw error with invalid FieldType', async () => { + // Arrange + const generator = new OAS3SpecGenerator( + [ + { + urlPath: '/user', + request: [ + { + name: 'id', + fieldIn: 'HEADER', + type: 'INVALID_VALUE', + }, + ], + } as any, + ], + {} as any + ); + // Act, Arrange + expect(() => generator.getSpec()).toThrow( + `FieldDataType INVALID_VALUE is not supported` + ); +}); diff --git a/packages/build/test/spec-generator/oas3.spec.ts b/packages/build/test/spec-generator/oas3.spec.ts new file mode 100644 index 00000000..93e4e25c --- /dev/null +++ b/packages/build/test/spec-generator/oas3.spec.ts @@ -0,0 +1,249 @@ +import { OAS3SpecGenerator } from '@vulcan/build/spec-generator'; +import { getSchemas, getConfig } from './schema'; +import * as jsYaml from 'js-yaml'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { get } from 'lodash'; + +const getGenerator = async () => { + const schema = await getSchemas(); + const config = getConfig(); + return new OAS3SpecGenerator(schema, config); +}; + +it('Should generate specification without error', async () => { + // Arrange + const generator = await getGenerator(); + // Act, Arrange + expect(async () => { + const spec = generator.getSpec(); + await fs.writeFile( + path.resolve(__dirname, 'oas3-spec.yaml'), + jsYaml.dump(spec), + 'utf-8' + ); + }).not.toThrow(); +}); + +it('Parameters in path should be converted to correct format', async () => { + // Arrange + const generator = await getGenerator(); + // Act + const spec = generator.getSpec(); + // Arrange + expect(Object.keys(spec.paths)[0]).toBe('/user/{id}'); + expect(Object.keys(spec.paths)[1]).toBe('/user/{id}/order/{oid}'); + expect(Object.keys(spec.paths)[2]).toBe('/users'); +}); + +it('Should extract the correct parameters', async () => { + // Arrange + const generator = await getGenerator(); + // Act + const spec = generator.getSpec(); + // Arrange + expect(spec.paths['/user/{id}']?.get.parameters[0]).toEqual( + expect.objectContaining({ + name: 'id', + in: 'path', + schema: expect.objectContaining({ + type: 'string', + minLength: 3, + maxLength: 10, + pattern: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', + }), + required: true, + }) + ); + expect(spec.paths['/user/{id}']?.get.parameters[1]).toEqual( + expect.objectContaining({ + name: 'agent', + in: 'header', + schema: expect.objectContaining({ + type: 'number', + minimum: 10, + maximum: 15, + }), + required: false, + }) + ); + expect(spec.paths['/user/{id}']?.get.parameters[2]).toEqual( + expect.objectContaining({ + name: 'force', + in: 'query', + schema: expect.objectContaining({ + type: 'boolean', + }), + required: false, + }) + ); + expect(spec.paths['/user/{id}']?.get.parameters[3]).toEqual( + expect.objectContaining({ + name: 'username', + in: 'query', + schema: expect.objectContaining({ + type: 'string', + enum: ['ivan', 'eason', 'freda'], + }), + required: false, + }) + ); +}); + +it('Should extract the correct response', async () => { + // Arrange + const generator = await getGenerator(); + // Act + const spec = generator.getSpec(); + // Arrange + expect( + get( + spec, + 'paths./user/{id}.get.responses.200.content.application/json.schema.properties.id' + ) + ).toEqual( + expect.objectContaining({ + type: 'string', + description: 'The unique-id of the user', + }) + ); + + expect( + get( + spec, + 'paths./user/{id}.get.responses.200.content.application/json.schema.properties.id' + ) + ).toEqual( + expect.objectContaining({ + type: 'string', + description: 'The unique-id of the user', + }) + ); + + expect( + get( + spec, + 'paths./user/{id}.get.responses.200.content.application/json.schema.properties.username' + ) + ).toEqual( + expect.objectContaining({ + type: 'string', + description: 'The username of the user', + }) + ); + + expect( + get( + spec, + 'paths./user/{id}.get.responses.200.content.application/json.schema.required' + ) + ).toEqual(['id', 'username']); + + expect( + get( + spec, + 'paths./user/{id}.get.responses.200.content.application/json.schema.properties.groups' + ) + ).toEqual( + expect.objectContaining({ + type: 'object', + description: 'The groups that the user has joined', + }) + ); + + expect( + get( + spec, + 'paths./user/{id}.get.responses.200.content.application/json.schema.properties.groups.properties.id' + ) + ).toEqual( + expect.objectContaining({ + type: 'string', + description: 'The unique-id of the group', + }) + ); + + expect( + get( + spec, + 'paths./user/{id}.get.responses.200.content.application/json.schema.properties.groups.properties.groupName' + ) + ).toEqual( + expect.objectContaining({ + type: 'string', + description: 'The groupName of the group', + }) + ); + + expect( + get( + spec, + 'paths./user/{id}.get.responses.200.content.application/json.schema.properties.groups.properties.public' + ) + ).toEqual( + expect.objectContaining({ + type: 'boolean', + description: 'Whether the group was public', + }) + ); + + expect( + get( + spec, + 'paths./user/{id}.get.responses.200.content.application/json.schema.properties.groups.required' + ) + ).toEqual(['id', 'groupName']); +}); + +it('Should extract correct errors', async () => { + // Arrange + const generator = await getGenerator(); + // Act + const spec = generator.getSpec(); + // Arrange + expect( + get( + spec, + 'paths./user/{id}.get.responses.400.content.application/json.examples.USER_NOT_FOUND' + ) + ).toEqual( + expect.objectContaining({ + description: `We can't find any user with the provided id`, + value: expect.objectContaining({ + code: 'USER_NOT_FOUND', + message: `We can't find any user with the provided id`, + }), + }) + ); + + expect( + get( + spec, + 'paths./user/{id}.get.responses.400.content.application/json.examples.AGENT_NOT_ALLOW' + ) + ).toEqual( + expect.objectContaining({ + description: `The agent is not allow`, + value: expect.objectContaining({ + code: 'AGENT_NOT_ALLOW', + message: `The agent is not allow`, + }), + }) + ); + + // We shouldn't set 400 error when there is no error code defined + expect( + get(spec, 'paths./user/{id}/order/{oid}.get.responses.400') + ).toBeUndefined(); +}); + +it('Should extract correct API description', async () => { + // Arrange + const generator = await getGenerator(); + // Act + const spec = generator.getSpec(); + // Arrange + expect(get(spec, 'paths./user/{id}.get.description')).toBe( + 'Get user information' + ); +}); diff --git a/packages/build/test/spec-generator/schema.ts b/packages/build/test/spec-generator/schema.ts new file mode 100644 index 00000000..3a7026b9 --- /dev/null +++ b/packages/build/test/spec-generator/schema.ts @@ -0,0 +1,127 @@ +/* eslint @nrwl/nx/enforce-module-boundaries: 0 */ +/* istanbul ignore file */ +import * as glob from 'glob'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { + APISchema, + Constraint, + IValidator, + ValidatorLoader, +} from '@vulcan/core'; +import * as jsYaml from 'js-yaml'; +import { sortBy } from 'lodash'; +import { IBuildOptions } from '@vulcan/build'; +import compose = require('koa-compose'); +import { + generateDataType, + normalizeDataType, + normalizeFieldIn, + setConstraints, + RawAPISchema, + SchemaParserMiddleware, + transformValidator, +} from '@vulcan/build/schema-parser/middleware'; +import * as sinon from 'ts-sinon'; + +const getSchemaPaths = () => + new Promise((resolve, reject) => { + glob(path.resolve(__dirname, 'schemas', '*.yaml'), (err, paths) => { + if (err) return reject(err); + resolve(sortBy(paths)); + }); + }); + +const getStubLoader = () => { + const validatorLoader = sinon.stubInterface(); + validatorLoader.getLoader.callsFake((name): IValidator => { + switch (name) { + case 'required': + return { + name: 'required', + validateSchema: () => true, + validateData: () => true, + getConstraints: () => [Constraint.Required()], + }; + case 'minValue': + return { + name: 'minValue', + validateSchema: () => true, + validateData: () => true, + getConstraints: (args) => [Constraint.MinValue(args.value)], + }; + case 'maxValue': + return { + name: 'maxValue', + validateSchema: () => true, + validateData: () => true, + getConstraints: (args) => [Constraint.MaxValue(args.value)], + }; + case 'minLength': + return { + name: 'minLength', + validateSchema: () => true, + validateData: () => true, + getConstraints: (args) => [Constraint.MinLength(args.value)], + }; + case 'maxLength': + return { + name: 'maxLength', + validateSchema: () => true, + validateData: () => true, + getConstraints: (args) => [Constraint.MaxLength(args.value)], + }; + case 'regex': + return { + name: 'regex', + validateSchema: () => true, + validateData: () => true, + getConstraints: (args) => [Constraint.Regex(args.value)], + }; + case 'enum': + return { + name: 'enum', + validateSchema: () => true, + validateData: () => true, + getConstraints: (args) => [Constraint.Enum(args.value)], + }; + default: + throw new Error(`Validator ${name} is not implemented in test bed.`); + } + }); + return validatorLoader; +}; + +export const getSchemas = async () => { + const schemas: RawAPISchema[] = []; + const paths = await getSchemaPaths(); + for (const schemaFile of paths) { + const content = await fs.readFile(schemaFile, 'utf-8'); + schemas.push(jsYaml.load(content) as RawAPISchema); + } + const loader = getStubLoader(); + // Load some required middleware + const execute = compose([ + normalizeFieldIn(), + transformValidator(), + generateDataType(), + normalizeDataType(), + setConstraints(loader), + ] as SchemaParserMiddleware[]); + for (const schema of schemas) { + await execute(schema); + } + return schemas as APISchema[]; +}; + +export const getConfig = (): IBuildOptions => { + return { + name: 'An API schema for testing', + version: '1.2.3', + description: `Some description with **markdown** supported.`, + // We don't care about the options of these components. + template: {} as any, + artifact: {} as any, + schemaParser: {} as any, + }; +}; diff --git a/packages/build/test/spec-generator/schemas/0-user.yaml b/packages/build/test/spec-generator/schemas/0-user.yaml new file mode 100644 index 00000000..01ec8d44 --- /dev/null +++ b/packages/build/test/spec-generator/schemas/0-user.yaml @@ -0,0 +1,70 @@ +name: user +urlPath: /user/:id +request: + - fieldName: id + fieldIn: path + validators: + - required + - name: minLength + args: + value: 3 + - name: maxLength + args: + value: 10 + - name: regex + args: + value: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + - fieldName: agent + fieldIn: header + type: number + validators: + - name: minValue + args: + value: 10 + - name: maxValue + args: + value: 15 + - fieldName: force + fieldIn: query + type: boolean + - fieldName: username + fieldIn: query + type: string + validators: + - name: enum + args: + value: + - ivan + - eason + - freda +description: Get user information +response: + - name: id + description: The unique-id of the user + type: string + required: true + - name: username + description: The username of the user + type: string + required: true + - name: age + description: The age of the user + type: number + - name: groups + description: The groups that the user has joined + type: + - name: id + description: The unique-id of the group + required: true + - name: groupName + description: The groupName of the group + required: true + - name: public + description: Whether the group was public + required: false + type: boolean +errors: + - code: USER_NOT_FOUND + message: We can't find any user with the provided id + - code: AGENT_NOT_ALLOW + message: The agent is not allow diff --git a/packages/build/test/spec-generator/schemas/1-user-order.yaml b/packages/build/test/spec-generator/schemas/1-user-order.yaml new file mode 100644 index 00000000..7556de97 --- /dev/null +++ b/packages/build/test/spec-generator/schemas/1-user-order.yaml @@ -0,0 +1,11 @@ +name: 'user order' +urlPath: /user/:id/order/:oid +request: + - fieldName: id + fieldIn: path + validators: + - required + - fieldName: oid + fieldIn: path + validators: + - required \ No newline at end of file diff --git a/packages/build/test/spec-generator/schemas/2-users.yaml b/packages/build/test/spec-generator/schemas/2-users.yaml new file mode 100644 index 00000000..90c11708 --- /dev/null +++ b/packages/build/test/spec-generator/schemas/2-users.yaml @@ -0,0 +1,2 @@ +name: users +urlPath: /users diff --git a/packages/core/src/models/artifact.ts b/packages/core/src/models/artifact.ts index 43cadac4..727779ca 100644 --- a/packages/core/src/models/artifact.ts +++ b/packages/core/src/models/artifact.ts @@ -17,9 +17,18 @@ error: message: 'You are not allowed to access this resource' */ +import { Constraint } from '../validators'; + export enum FieldInType { QUERY = 'QUERY', HEADER = 'HEADER', + PATH = 'PATH', +} + +export enum FieldDataType { + BOOLEAN = 'BOOLEAN', + NUMBER = 'NUMBER', + STRING = 'STRING', } export interface ValidatorDefinition { @@ -32,7 +41,16 @@ export interface RequestParameter { // the field put in query parameter or headers fieldIn: FieldInType; description: string; + type: FieldDataType; validators: Array; + constraints: Array; +} + +export interface ResponseProperty { + name: string; + description?: string; + type: FieldDataType | Array; + required?: boolean; } export interface ErrorInfo { @@ -49,7 +67,8 @@ export interface APISchema { templateSource: string; request: Array; errors: Array; - response: any; + response: Array; + description?: string; } export interface BuiltArtifact { diff --git a/packages/core/src/validators/constraints.ts b/packages/core/src/validators/constraints.ts new file mode 100644 index 00000000..76cf3226 --- /dev/null +++ b/packages/core/src/validators/constraints.ts @@ -0,0 +1,134 @@ +import { intersection } from 'lodash'; + +export abstract class Constraint { + static Required() { + return new RequiredConstraint(); + } + + static MinValue(minValue: number) { + return new MinValueConstraint(minValue); + } + + static MaxValue(maxValue: number) { + return new MaxValueConstraint(maxValue); + } + + static MinLength(minLength: number) { + return new MinLengthConstraint(minLength); + } + + static MaxLength(maxLength: number) { + return new MaxLengthConstraint(maxLength); + } + + static Regex(regex: string) { + return new RegexConstraint(regex); + } + + static Enum(list: Array) { + return new EnumConstraint(list); + } + + abstract compose(constraint: Constraint): Constraint; +} + +export class RequiredConstraint extends Constraint { + public compose() { + // No matter what other required constraint is, we always return a required constraint + return new RequiredConstraint(); + } +} + +export class MinValueConstraint extends Constraint { + constructor(private minValue: number) { + super(); + } + + public getMinValue() { + return this.minValue; + } + + public compose(constraint: MinValueConstraint): MinValueConstraint { + return new MinValueConstraint( + Math.max(this.minValue, constraint.getMinValue()) + ); + } +} + +export class MaxValueConstraint extends Constraint { + constructor(private maxValue: number) { + super(); + } + + public getMaxValue() { + return this.maxValue; + } + + public compose(constraint: MaxValueConstraint): MaxValueConstraint { + return new MaxValueConstraint( + Math.min(this.maxValue, constraint.getMaxValue()) + ); + } +} + +export class MinLengthConstraint extends Constraint { + constructor(private minLength: number) { + super(); + } + + public getMinLength() { + return this.minLength; + } + + public compose(constraint: MinLengthConstraint): MinLengthConstraint { + return new MinLengthConstraint( + Math.max(this.minLength, constraint.getMinLength()) + ); + } +} + +export class MaxLengthConstraint extends Constraint { + constructor(private maxLength: number) { + super(); + } + + public getMaxLength() { + return this.maxLength; + } + + public compose(constraint: MaxLengthConstraint): MaxLengthConstraint { + return new MaxLengthConstraint( + Math.min(this.maxLength, constraint.getMaxLength()) + ); + } +} + +export class RegexConstraint extends Constraint { + constructor(private regex: string) { + super(); + } + + public getRegex() { + return this.regex; + } + + public compose(): RegexConstraint { + throw new Error(`Can use multiple RegexConstraint at the same time.`); + } +} + +export class EnumConstraint extends Constraint { + constructor(private list: Array) { + super(); + } + + public getList() { + return this.list; + } + + public compose(constraint: EnumConstraint): EnumConstraint { + return new EnumConstraint( + intersection(this.getList(), constraint.getList()) + ); + } +} diff --git a/packages/core/src/validators/index.ts b/packages/core/src/validators/index.ts index 535749a7..7b63342f 100644 --- a/packages/core/src/validators/index.ts +++ b/packages/core/src/validators/index.ts @@ -1,2 +1,3 @@ export * from './interface'; export * from './validatorLoader'; +export * from './constraints'; diff --git a/packages/core/src/validators/interface.ts b/packages/core/src/validators/interface.ts index 4794d9ee..7d7b9013 100644 --- a/packages/core/src/validators/interface.ts +++ b/packages/core/src/validators/interface.ts @@ -1,3 +1,5 @@ +import { Constraint } from './constraints'; + export interface IValidator { // validator name name: string; @@ -5,4 +7,7 @@ export interface IValidator { validateSchema(args: T): boolean; // validate input data validateData(data: string, args: T): boolean; + // TODO: Find a better way to get constraints. + // Get the constraints of this validator + getConstraints?(args: T): Constraint[]; } diff --git a/packages/core/test/validators/constraint.spec.ts b/packages/core/test/validators/constraint.spec.ts new file mode 100644 index 00000000..502eaec1 --- /dev/null +++ b/packages/core/test/validators/constraint.spec.ts @@ -0,0 +1,82 @@ +import { + Constraint, + EnumConstraint, + MaxLengthConstraint, + MaxValueConstraint, + MinLengthConstraint, + MinValueConstraint, + RequiredConstraint, +} from '../../src/validators'; + +it('Required constraint compose should always return required constraint', async () => { + // Arrange + const constraint1: Constraint = Constraint.Required(); + const constraint2 = Constraint.Required(); + // Act + const composed = constraint1.compose(constraint2); + // Arrange + expect(composed instanceof RequiredConstraint).toBeTruthy(); +}); + +it('MinValue constraint compose should maximum the value', async () => { + // Arrange + const constraint1: Constraint = Constraint.MinValue(1); + const constraint2 = Constraint.MinValue(2); + // Act + const composed = constraint1.compose(constraint2); + // Arrange + expect(composed instanceof MinValueConstraint).toBeTruthy(); + expect((composed as MinValueConstraint).getMinValue()).toBe(2); +}); + +it('MaxValue constraint compose should minimize the value', async () => { + // Arrange + const constraint1: Constraint = Constraint.MaxValue(1); + const constraint2 = Constraint.MaxValue(2); + // Act + const composed = constraint1.compose(constraint2); + // Arrange + expect(composed instanceof MaxValueConstraint).toBeTruthy(); + expect((composed as MaxValueConstraint).getMaxValue()).toBe(1); +}); + +it('MinLength constraint compose should maximum the value', async () => { + // Arrange + const constraint1: Constraint = Constraint.MinLength(1); + const constraint2 = Constraint.MinLength(2); + // Act + const composed = constraint1.compose(constraint2); + // Arrange + expect(composed instanceof MinLengthConstraint).toBeTruthy(); + expect((composed as MinLengthConstraint).getMinLength()).toBe(2); +}); + +it('MaxLength constraint compose should minimize the value', async () => { + // Arrange + const constraint1: Constraint = Constraint.MaxLength(1); + const constraint2 = Constraint.MaxLength(2); + // Act + const composed = constraint1.compose(constraint2); + // Arrange + expect(composed instanceof MaxLengthConstraint).toBeTruthy(); + expect((composed as MaxLengthConstraint).getMaxLength()).toBe(1); +}); + +it('RegexConstraint constraint compose should throw error', async () => { + // Arrange + const constraint1: Constraint = Constraint.Regex('someExp'); + const constraint2 = Constraint.Regex('someExp'); + // Act, Arrange + expect(() => constraint1.compose(constraint2)).toThrow(); +}); + +it('Enum constraint compose should return the intersection of them', async () => { + // Arrange + const constraint1: Constraint = Constraint.Enum([1, 2, 3, 4, 5]); + const constraint2 = Constraint.Enum([3, 4, 5, 6, 7]); + // Act + const composed = constraint1.compose(constraint2); + // Arrange + expect(composed instanceof EnumConstraint).toBeTruthy(); + expect((composed as EnumConstraint).getList()).toEqual([3, 4, 5]); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index bbd60494..587bd73d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,6 +38,12 @@ "@vulcan/build/schema-parser/*": [ "packages/build/src/lib/schema-parser/*" ], + "@vulcan/build/spec-generator": [ + "packages/build/src/lib/spec-generator/index" + ], + "@vulcan/build/spec-generator/*": [ + "packages/build/src/lib/spec-generator/*" + ], "@vulcan/build/models": ["packages/build/src/models/index"], "@vulcan/build/containers": ["packages/build/src/containers/index"], "@vulcan/build/options": ["packages/build/src/options/index"] diff --git a/yarn.lock b/yarn.lock index fa19e610..8eb29411 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3684,6 +3684,13 @@ open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openapi3-ts@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-2.0.2.tgz#a200dd838bf24c9086c8eedcfeb380b7eb31e82a" + integrity sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw== + dependencies: + yaml "^1.10.2" + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -4623,7 +4630,7 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@^1.10.0: +yaml@^1.10.0, yaml@^1.10.2: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==