Skip to content

Commit

Permalink
feat(json-schema): add async api mapper
Browse files Browse the repository at this point in the history
  • Loading branch information
Romakita committed Oct 26, 2023
1 parent 3240a79 commit a3a39c4
Show file tree
Hide file tree
Showing 13 changed files with 846 additions and 59 deletions.
80 changes: 80 additions & 0 deletions packages/specs/schema/src/components/async-api/channelsMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {camelCase} from "change-case";
import {OperationVerbs} from "../../constants/OperationVerbs";
import {JsonMethodStore} from "../../domain/JsonMethodStore";
import {JsonMethodPath} from "../../domain/JsonOperation";
import {JsonSchemaOptions} from "../../interfaces/JsonSchemaOptions";
import {execMapper, registerJsonSchemaMapper} from "../../registries/JsonSchemaMapperContainer";
import {buildPath} from "../../utils/buildPath";
import {getJsonEntityStore} from "../../utils/getJsonEntityStore";
import {getOperationsStores} from "../../utils/getOperationsStores";
import {removeHiddenOperation} from "../../utils/removeHiddenOperation";

const ALLOWED_VERBS = [OperationVerbs.PUBLISH, OperationVerbs.SUBSCRIBE];

function pushToChannels(options: JsonSchemaOptions) {
return (
channels: any,
{
operationPath,
operationStore
}: {
operationPath: JsonMethodPath;
operationStore: JsonMethodStore;
}
) => {
const path = options.ctrlRootPath || "/";
const method = operationPath.method.toLowerCase();
const operationId = camelCase(`${method.toLowerCase()} ${operationStore.parent.schema.getName()}`);

const message = execMapper("message", [operationStore, operationPath], options);

return {
...channels,
[path]: {
...(channels as any)[path],
[method]: {
...(channels as any)[path]?.[method],
operationId,
message: {
oneOf: [...((channels as any)[path]?.[method]?.message?.oneOf || []), message]
}
}
}
};
};
}

function expandOperationPaths(options: JsonSchemaOptions) {
return (operationStore: JsonMethodStore) => {
const operationPaths = operationStore.operation.getAllowedOperationPath(ALLOWED_VERBS);

if (operationPaths.length === 0) {
return [];
}

return operationPaths.map((operationPath) => {
return {
operationPath,
operationStore
};
});
};
}

export function channelsMapper(model: any, {channels, rootPath, ...options}: JsonSchemaOptions) {
const store = getJsonEntityStore(model);
const ctrlPath = store.path;
const ctrlRootPath = buildPath(rootPath + ctrlPath);

options = {
...options,
ctrlRootPath
};

return [...getOperationsStores(model).values()]
.filter(removeHiddenOperation)
.flatMap(expandOperationPaths(options))
.reduce(pushToChannels(options), channels);
}

registerJsonSchemaMapper("channels", channelsMapper);
23 changes: 23 additions & 0 deletions packages/specs/schema/src/components/async-api/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {getValue, Type, uniqBy} from "@tsed/core";
import {SpecTypes} from "../../domain/SpecTypes";
import {execMapper, registerJsonSchemaMapper} from "../../registries/JsonSchemaMapperContainer";
import {SpecSerializerOptions} from "../../utils/getSpec";

function generate(model: Type<any>, options: SpecSerializerOptions) {
const specJson: any = {
channels: execMapper("channels", [model], options)
};

specJson.tags = uniqBy(options.tags, "name");

if (options.components?.schemas && Object.keys(options.components.schemas).length) {
specJson.components = {
...options.components,
schemas: options.components.schemas
};
}

return specJson;
}

registerJsonSchemaMapper("generate", generate, SpecTypes.ASYNCAPI);
61 changes: 61 additions & 0 deletions packages/specs/schema/src/components/async-api/messageMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {cleanObject, getValue} from "@tsed/core";
import {OperationVerbs} from "../../constants/OperationVerbs";
import {JsonMethodStore} from "../../domain/JsonMethodStore";
import {JsonMethodPath} from "../../domain/JsonOperation";
import {SpecTypes} from "../../domain/SpecTypes";
import {JsonSchemaOptions} from "../../interfaces/JsonSchemaOptions";
import {execMapper, registerJsonSchemaMapper} from "../../registries/JsonSchemaMapperContainer";
import {makeOf} from "../../utils/somethingOf";

export function messageMapper(
jsonOperationStore: JsonMethodStore,
operationPath: JsonMethodPath,
{tags = [], defaultTags = [], ...options}: JsonSchemaOptions = {}
) {
const {path: event, method} = operationPath;

const messageKey = String(event);

let message: any = getValue(
options.components,
"messages." + event,
cleanObject({
description: jsonOperationStore.operation.get("description"),
summary: jsonOperationStore.operation.get("summary")
})
);

if (method.toUpperCase() === OperationVerbs.PUBLISH) {
const payload = execMapper("payload", [jsonOperationStore, operationPath], options);

if (payload) {
message.payload = payload;
}

const responses = jsonOperationStore.operation
.getAllowedOperationPath([OperationVerbs.SUBSCRIBE])
.map((operationPath) => {
return execMapper("message", [jsonOperationStore, operationPath], options);
})
.filter(Boolean);

const responsesSchema = makeOf("oneOf", responses);

if (responsesSchema) {
message["x-response"] = responsesSchema;
}
} else {
const response = execMapper("response", [jsonOperationStore, operationPath], options);

if (response) {
message["x-response"] = response;
}
}

options.components!.messages = options.components!.messages || {};
options.components!.messages[messageKey] = message;

return {$ref: `#/components/messages/${messageKey}`};
}

registerJsonSchemaMapper("message", messageMapper, SpecTypes.ASYNCAPI);
60 changes: 60 additions & 0 deletions packages/specs/schema/src/components/async-api/payloadMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {setValue} from "@tsed/core";
import {pascalCase} from "change-case";
import {JsonMethodStore} from "../../domain/JsonMethodStore";
import {JsonMethodPath, JsonOperation} from "../../domain/JsonOperation";
import {JsonParameter} from "../../domain/JsonParameter";
import {isParameterType, JsonParameterTypes} from "../../domain/JsonParameterTypes";
import {SpecTypes} from "../../domain/SpecTypes";
import {JsonSchemaOptions} from "../../interfaces/JsonSchemaOptions";
import {execMapper, registerJsonSchemaMapper} from "../../registries/JsonSchemaMapperContainer";
import {popGenerics} from "../../utils/generics";
import {makeOf} from "../../utils/somethingOf";

function mapOptions(parameter: JsonParameter, options: JsonSchemaOptions = {}) {
return {
...options,
groups: parameter.groups,
groupsName: parameter.groupsName
};
}

function getParameters(jsonOperation: JsonOperation, options: JsonSchemaOptions): JsonParameter[] {
return jsonOperation.get("parameters").filter((parameter: JsonParameter) => isParameterType(parameter.get("in")));
}

export function payloadMapper(jsonOperationStore: JsonMethodStore, operationPath: JsonMethodPath, options: JsonSchemaOptions) {
const parameters = getParameters(jsonOperationStore.operation, options);
const payloadName = pascalCase([operationPath.path, operationPath.method, "Payload"].join(" "));

setValue(options, `components.schemas.${payloadName}`, {});

const allOf = parameters
.map((parameter) => {
const opts = mapOptions(parameter, options);
const jsonSchema = execMapper("item", [parameter.$schema], {
...opts,
...popGenerics(parameter)
});

switch (parameter.get("in")) {
case JsonParameterTypes.BODY:
return jsonSchema;
case JsonParameterTypes.QUERY:
case JsonParameterTypes.PATH:
case JsonParameterTypes.HEADER:
return {
type: "object",
properties: {
[parameter.get("name")]: jsonSchema
}
};
}

return jsonSchema;
}, {})
.filter(Boolean);

return makeOf("allOf", allOf);
}

registerJsonSchemaMapper("payload", payloadMapper, SpecTypes.ASYNCAPI);
69 changes: 69 additions & 0 deletions packages/specs/schema/src/components/async-api/responseMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {setValue} from "@tsed/core";
import {pascalCase} from "change-case";
import {JsonMethodStore} from "../../domain/JsonMethodStore";
import {JsonMethodPath} from "../../domain/JsonOperation";
import {SpecTypes} from "../../domain/SpecTypes";
import {JsonSchemaOptions} from "../../interfaces/JsonSchemaOptions";
import {execMapper, registerJsonSchemaMapper} from "../../registries/JsonSchemaMapperContainer";
import {makeOf} from "../../utils/somethingOf";

export function responsePayloadMapper(jsonOperationStore: JsonMethodStore, operationPath: JsonMethodPath, options: JsonSchemaOptions) {
const responses = jsonOperationStore.operation.getResponses();
const statuses: number[] = [];
const statusesTexts: string[] = [];
const successSchemes: unknown[] = [];
const errorSchemes: unknown[] = [];

[...responses.entries()].forEach(([status, jsonResponse]) => {
const response = execMapper("map", [jsonResponse], options);

statuses.push(+status);

statusesTexts.push(response.description);

if (+status !== 204) {
const {content} = response;
const schema = content[Object.keys(content)[0]];

if (+status >= 200 && +status < 400) {
successSchemes.push(schema);
} else {
successSchemes.push(schema);
}
}
});

const responsePayloadName = pascalCase([operationPath.path, operationPath.method, "Response"].join(" "));
const responsePayload = {
type: "object",
properties: {
status: {
type: "number",
enum: statuses
},
statusText: {
type: "string",
enum: statusesTexts
}
},
required: ["status"]
};

const dataSchema = makeOf("oneOf", successSchemes);

if (dataSchema) {
setValue(responsePayload, "properties.data", dataSchema);
}

const errorSchema = makeOf("oneOf", errorSchemes);

if (errorSchemes.length) {
setValue(responsePayload, "properties.error", errorSchema);
}

setValue(options, `components.schemas.${responsePayloadName}`, responsePayload);

return {$ref: `#/components/schemas/${responsePayloadName}`};
}

registerJsonSchemaMapper("response", responsePayloadMapper, SpecTypes.ASYNCAPI);
5 changes: 5 additions & 0 deletions packages/specs/schema/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
* @file Automatically generated by barrelsby.
*/

export * from "./async-api/channelsMapper";
export * from "./async-api/generate";
export * from "./async-api/messageMapper";
export * from "./async-api/payloadMapper";
export * from "./async-api/responseMapper";
export * from "./default/anyMapper";
export * from "./default/classMapper";
export * from "./default/genericsMapper";
Expand Down
49 changes: 15 additions & 34 deletions packages/specs/schema/src/constants/OperationVerbs.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,3 @@
import {Operation} from "../decorators/operations/operation";

export const ALLOWED_VERBS = [
"all",
"checkout",
"connect",
"copy",
"delete",
"get",
"head",
"lock",
"merge",
"mkactivity",
"mkcol",
"move",
"m-search",
"notify",
"options",
"param",
"patch",
"post",
"propfind",
"propatch",
"purge",
"put",
"report",
"search",
"subscribe",
"publish",
"trace",
"unlock",
"unsuscribe"
];

export enum OperationVerbs {
ALL = "ALL", // special key
GET = "GET",
Expand All @@ -47,6 +13,21 @@ export enum OperationVerbs {
CUSTOM = "CUSTOM"
}

export const OPERATION_HTTP_VERBS = [
OperationVerbs.ALL,
OperationVerbs.GET,
OperationVerbs.POST,
OperationVerbs.PUT,
OperationVerbs.PATCH,
OperationVerbs.HEAD,
OperationVerbs.DELETE,
OperationVerbs.OPTIONS,
OperationVerbs.TRACE,
OperationVerbs.CUSTOM
];

export const OPERATION_WS_VERBS = [OperationVerbs.PUBLISH, OperationVerbs.SUBSCRIBE];

/**
* @deprecated Use OperationVerbs instead of OperationMethods
*/
Expand Down
6 changes: 5 additions & 1 deletion packages/specs/schema/src/domain/JsonOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ export class JsonOperation extends JsonMap<JsonOperationOptions> {
return this;
}

getAllowedOperationPath(allowedVerbs: string[]) {
getAllowedOperationPath(allowedVerbs?: string[]) {
if (!allowedVerbs) {
return [...this.operationPaths.values()];
}

return [...this.operationPaths.values()].filter(({method}) => method && allowedVerbs.includes(method.toUpperCase()));
}
}
Loading

0 comments on commit a3a39c4

Please sign in to comment.