@compas/code-gen
Version:
Generate various boring parts of your server
486 lines (410 loc) • 11.3 kB
JavaScript
import { AppError, isNil } from "@compas/stdlib";
import { lowerCaseFirst, upperCaseFirst } from "../utils.js";
import { RouteInvalidationType } from "./RouteInvalidationType.js";
import { TypeBuilder } from "./TypeBuilder.js";
import { buildOrInfer } from "./utils.js";
export class RouteBuilder extends TypeBuilder {
static baseData = {
tags: [],
idempotent: false,
invalidates: [],
};
constructor(method, group, name, path) {
super("route", group, name);
this.data.method = method;
this.data.path = path;
this.data.tags = [];
this.data.idempotent = false;
this.invalidates = [];
this.queryBuilder = undefined;
this.paramsBuilder = undefined;
this.bodyBuilder = undefined;
this.responseBuilder = undefined;
}
/**
* @param {...string} values
* @returns {RouteBuilder}
*/
tags(...values) {
for (const v of values) {
this.data.tags.push(lowerCaseFirst(v));
}
return this;
}
/**
* Guarantee to the client that this call does not have any side-effects.
* Can only be used for "POST" requests. Doesn't do anything to the generated router,
* but some clients may use it to their advantage like the react-query generator.
*
* @returns {RouteBuilder}
*/
idempotent() {
if (this.data.method !== "POST") {
throw new Error(`Can only set idempotent on POST routes`);
}
this.data.idempotent = true;
return this;
}
/**
* @param {import("../../index.js").TypeBuilderLike} builder
* @returns {RouteBuilder}
*/
query(builder) {
this.queryBuilder = builder;
return this;
}
/**
* @param {import("../../index.js").TypeBuilderLike} builder
* @returns {RouteBuilder}
*/
params(builder) {
this.paramsBuilder = builder;
return this;
}
/**
* @param {import("../../index.js").TypeBuilderLike} builder
* @returns {RouteBuilder}
*/
body(builder) {
if (["POST", "PUT", "PATCH"].indexOf(this.data.method) === -1) {
throw new Error("Can only use body on POST, PUT or PATCH routes");
}
this.bodyBuilder = builder;
return this;
}
/**
* Specify routes that can be invalidated when this route is called.
*
* @param {...import("./RouteInvalidationType.js").RouteInvalidationType} invalidates
* @returns {RouteBuilder}
*/
invalidations(...invalidates) {
if (["POST", "PUT", "PATCH", "DELETE"].indexOf(this.data.method) === -1) {
throw new Error(
"Can only use invalidations on POST, PUT, PATCH or DELETE routes.",
);
}
this.invalidates = invalidates;
return this;
}
/**
* Prefer the api client / router to use form-data instead of expecting json.
* This can be used to conform to non Compas api's.
*
* @returns {RouteBuilder}
*/
preferFormData() {
this.data.metadata ??= {};
this.data.metadata.requestBodyType = "form-data";
return this;
}
/**
* @param {import("../../index.js").TypeBuilderLike} builder
* @returns {RouteBuilder}
*/
response(builder) {
this.responseBuilder = builder;
return this;
}
build() {
const result = super.build();
result.invalidations = [];
for (const invalidation of this.invalidates) {
result.invalidations.push(invalidation.build());
}
if (this.queryBuilder) {
result.query = buildOrInfer(this.queryBuilder);
if (isNil(result.query.name) && result.query.type !== "reference") {
result.query.group = result.group;
result.query.name = `${result.name}Query`;
}
}
if (this.bodyBuilder) {
result.body = buildOrInfer(this.bodyBuilder);
if (isNil(result.body.name) && result.body.type !== "reference") {
result.body.group = result.group;
result.body.name = `${result.name}Body`;
}
}
if (this.responseBuilder) {
result.response = buildOrInfer(this.responseBuilder);
if (isNil(result.response.name) && result.response.type !== "reference") {
result.response.group = result.group;
result.response.name = `${result.name}Response`;
}
}
const pathParamKeys = collectPathParams(result.path);
if (new Set(pathParamKeys).size !== pathParamKeys.length) {
const set = new Set(pathParamKeys);
for (const pathParam of pathParamKeys) {
if (!set.has(pathParam)) {
throw AppError.serverError({
message: `Route ${upperCaseFirst(result.group)}${upperCaseFirst(
result.name,
)} has defined ':${pathParam}' multiple times. Remove or rename one of the occurrences.`,
});
}
set.delete(pathParam);
}
}
if (this.paramsBuilder || pathParamKeys.length > 0) {
const paramsResult =
this.paramsBuilder ?
buildOrInfer(this.paramsBuilder)
: buildOrInfer({});
if (paramsResult.type !== "object") {
throw AppError.serverError({
message: `\`.params()\` should be an 'T.object()'. Found '${
paramsResult.type
}' in '${upperCaseFirst(result.group)}${upperCaseFirst(result.name)}'`,
});
}
paramsResult.group = result.group;
paramsResult.name = `${result.name}Params`;
for (const param of pathParamKeys) {
if (isNil(paramsResult.keys?.[param])) {
throw AppError.serverError({
message: `Route ${upperCaseFirst(result.group)}${upperCaseFirst(
result.name,
)} is missing a type definition for '${param}' parameter.`,
});
}
if (paramsResult.keys[param].isOptional) {
throw AppError.serverError({
message: `Route ${upperCaseFirst(result.group)}${upperCaseFirst(
result.name,
)} is using an optional value for the '${param}' parameter. This is not supported.`,
});
}
}
for (const key of Object.keys(paramsResult.keys ?? {})) {
if (pathParamKeys.indexOf(key) === -1) {
throw AppError.serverError({
message: `Route ${upperCaseFirst(result.group)}${upperCaseFirst(
result.name,
)} has type definition for '${key}' but is not found in the path: ${
result.path
}`,
});
}
}
result.params = paramsResult;
}
return result;
}
}
/**
* Collect all path params
*
* @param {string} path
* @returns {Array<string>}
*/
function collectPathParams(path) {
const keys = [];
for (const part of path.split("/")) {
if (part.startsWith(":")) {
keys.push(part.substring(1));
}
}
return keys;
}
export class RouteCreator {
constructor(group, path) {
this.data = {
group,
path: path ?? "",
};
if (this.data.path.startsWith("/")) {
this.data.path = this.data.path.slice(1);
}
/** @type {Array<string>} */
this.defaultTags = [];
this.queryBuilder = undefined;
this.paramsBuilder = undefined;
this.bodyBuilder = undefined;
this.responseBuilder = undefined;
}
/**
* @param {...string} values
* @returns {RouteCreator}
*/
tags(...values) {
this.defaultTags.push(...values);
return this;
}
/**
* @param {import("../../index.js").TypeBuilderLike} builder
* @returns {RouteCreator}
*/
query(builder) {
this.queryBuilder = builder;
return this;
}
/**
* @param {import("../../index.js").TypeBuilderLike} builder
* @returns {RouteCreator}
*/
params(builder) {
this.paramsBuilder = builder;
return this;
}
/**
* @param {import("../../index.js").TypeBuilderLike} builder
* @returns {RouteCreator}
*/
body(builder) {
this.bodyBuilder = builder;
return this;
}
/**
* @param {import("../../index.js").TypeBuilderLike} builder
* @returns {RouteCreator}
*/
response(builder) {
this.responseBuilder = builder;
return this;
}
/**
* Generate `queryClient.invalidateQueries` calls in the react-query generator, which
* can be executed when the generated hook is called.
*
* @param {string} group
* @param {string} [name]
* @param {import("../generated/common/types.d.ts").StructureRouteInvalidationDefinition["properties"]} [properties]
* @returns {RouteInvalidationType}
*/
invalidates(group, name, properties) {
return new RouteInvalidationType(group, name, properties);
}
/**
* @param {string} name
* @param {string} path
* @returns {RouteCreator}
*/
group(name, path) {
return new RouteCreator(name, concatenateRoutePaths(this.data.path, path));
}
/**
* @param {string} [path]
* @param {string} [name]
* @returns {RouteBuilder}
*/
get(path, name) {
return this.create(
"GET",
this.data.group,
name || "get",
concatenateRoutePaths(this.data.path, path || "/"),
);
}
/**
* @param {string} [path]
* @param {string} [name]
* @returns {RouteBuilder}
*/
post(path, name) {
return this.create(
"POST",
this.data.group,
name || "post",
concatenateRoutePaths(this.data.path, path || "/"),
);
}
/**
* @param {string} [path]
* @param {string} [name]
* @returns {RouteBuilder}
*/
put(path, name) {
return this.create(
"PUT",
this.data.group,
name || "put",
concatenateRoutePaths(this.data.path, path || "/"),
);
}
/**
* @param {string} [path]
* @param {string} [name]
* @returns {RouteBuilder}
*/
patch(path, name) {
return this.create(
"PATCH",
this.data.group,
name || "patch",
concatenateRoutePaths(this.data.path, path || "/"),
);
}
/**
* @param {string} [path]
* @param {string} [name]
* @returns {RouteBuilder}
*/
delete(path, name) {
return this.create(
"DELETE",
this.data.group,
name || "delete",
concatenateRoutePaths(this.data.path, path || "/"),
);
}
/**
* @param {string} [path]
* @param {string} [name]
* @returns {RouteBuilder}
*/
head(path, name) {
return this.create(
"HEAD",
this.data.group,
name || "get",
concatenateRoutePaths(this.data.path, path || "/"),
);
}
/**
* Create a new RouteBuilder and add the defaults if exists.
*
* @private
*
* @param {string} method
* @param {string} group
* @param {string} name
* @param {string} path
* @returns {RouteBuilder}
*/
create(method, group, name, path) {
const b = new RouteBuilder(method, group, name, path);
b.tags(...this.defaultTags);
if (!isNil(this.paramsBuilder)) {
b.params(this.paramsBuilder);
}
if (!isNil(this.queryBuilder)) {
b.query(this.queryBuilder);
}
if (
!isNil(this.bodyBuilder) &&
["POST", "PUT", "PATCH"].indexOf(method) !== -1
) {
b.body(this.bodyBuilder);
}
if (!isNil(this.responseBuilder)) {
b.response(this.responseBuilder);
}
return b;
}
}
/**
* @param {string} path1
* @param {string} path2
* @returns {string}
*/
function concatenateRoutePaths(path1, path2) {
if (!path1.endsWith("/")) {
path1 += "/";
}
if (path2.startsWith("/")) {
path2 = path2.substring(1);
}
return path1 + path2;
}