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
3 changes: 3 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"watch": "tsup-node --watch",
"lint": "eslint src --ext ts",
"test": "vitest run",
"update-baseline": "UPDATE_BASELINE=1 vitest run test/openapi",
"pack": "pnpm pack"
},
"keywords": [
Expand Down Expand Up @@ -124,13 +125,15 @@
"@zenstackhq/common-helpers": "workspace:*",
"@zenstackhq/orm": "workspace:*",
"decimal.js": "catalog:",
"openapi-types": "^12.1.3",
"superjson": "^2.2.3",
"ts-japi": "^1.12.1",
"ts-pattern": "catalog:",
"url-pattern": "^1.0.3",
"zod-validation-error": "catalog:"
},
"devDependencies": {
"@readme/openapi-parser": "^6.0.0",
"@sveltejs/kit": "catalog:",
"@types/body-parser": "^1.19.6",
"@types/express": "^5.0.0",
Expand Down
24 changes: 24 additions & 0 deletions packages/server/src/api/common/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
import z from 'zod';

export const loggerSchema = z.union([z.enum(['debug', 'info', 'warn', 'error']).array(), z.function()]);

const fieldSlicingSchema = z.looseObject({
includedFilterKinds: z.string().array().optional(),
excludedFilterKinds: z.string().array().optional(),
});

const modelSlicingSchema = z.looseObject({
includedOperations: z.array(z.string()).optional(),
excludedOperations: z.array(z.string()).optional(),
fields: z.record(z.string(), fieldSlicingSchema).optional(),
});

const slicingSchema = z.looseObject({
includedModels: z.array(z.string()).optional(),
excludedModels: z.array(z.string()).optional(),
models: z.record(z.string(), modelSlicingSchema).optional(),
includedProcedures: z.array(z.string()).optional(),
excludedProcedures: z.array(z.string()).optional(),
});

export const queryOptionsSchema = z.looseObject({
omit: z.record(z.string(), z.record(z.string(), z.boolean())).optional(),
slicing: slicingSchema.optional(),
});
115 changes: 115 additions & 0 deletions packages/server/src/api/common/spec-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
import type { QueryOptions } from '@zenstackhq/orm';
import { ExpressionUtils, type AttributeApplication, type SchemaDef } from '@zenstackhq/orm/schema';

/**
* Checks if a model is included based on slicing options.
*/
export function isModelIncluded(modelName: string, queryOptions?: QueryOptions<any>): boolean {
const slicing = queryOptions?.slicing;
if (!slicing) return true;

const excluded = slicing.excludedModels as readonly string[] | undefined;
if (excluded?.includes(modelName)) return false;

const included = slicing.includedModels as readonly string[] | undefined;
if (included && !included.includes(modelName)) return false;

return true;
}

/**
* Checks if a CRUD operation is included for a model based on slicing options.
*/
export function isOperationIncluded(modelName: string, op: string, queryOptions?: QueryOptions<any>): boolean {
const slicing = queryOptions?.slicing;
if (!slicing?.models) return true;

const modelKey = lowerCaseFirst(modelName);
const modelSlicing = (slicing.models as Record<string, any>)[modelKey] ?? (slicing.models as any).$all;
if (!modelSlicing) return true;

const excluded = modelSlicing.excludedOperations as readonly string[] | undefined;
if (excluded?.includes(op)) return false;

const included = modelSlicing.includedOperations as readonly string[] | undefined;
if (included && !included.includes(op)) return false;

return true;
}

/**
* Checks if a procedure is included based on slicing options.
*/
export function isProcedureIncluded(procName: string, queryOptions?: QueryOptions<any>): boolean {
const slicing = queryOptions?.slicing;
if (!slicing) return true;

const excluded = slicing.excludedProcedures as readonly string[] | undefined;
if (excluded?.includes(procName)) return false;

const included = slicing.includedProcedures as readonly string[] | undefined;
if (included && !included.includes(procName)) return false;

return true;
}

/**
* Checks if a field should be omitted from the output schema based on queryOptions.omit.
*/
export function isFieldOmitted(modelName: string, fieldName: string, queryOptions?: QueryOptions<any>): boolean {
const omit = queryOptions?.omit as Record<string, Record<string, boolean>> | undefined;
return omit?.[modelName]?.[fieldName] === true;
}

/**
* Returns the list of model names from the schema that pass the slicing filter.
*/
export function getIncludedModels(schema: SchemaDef, queryOptions?: QueryOptions<any>): string[] {
return Object.keys(schema.models).filter((name) => isModelIncluded(name, queryOptions));
}

/**
* Checks if a filter kind is allowed for a specific field based on slicing options.
*/
export function isFilterKindIncluded(
modelName: string,
fieldName: string,
filterKind: string,
queryOptions?: QueryOptions<any>,
): boolean {
const slicing = queryOptions?.slicing;
if (!slicing?.models) return true;

const modelKey = lowerCaseFirst(modelName);
const modelSlicing = (slicing.models as Record<string, any>)[modelKey] ?? (slicing.models as any).$all;
if (!modelSlicing?.fields) return true;

const fieldSlicing = modelSlicing.fields[fieldName] ?? modelSlicing.fields.$all;
if (!fieldSlicing) return true;

const excluded = fieldSlicing.excludedFilterKinds as readonly string[] | undefined;
if (excluded?.includes(filterKind)) return false;

const included = fieldSlicing.includedFilterKinds as readonly string[] | undefined;
if (included && !included.includes(filterKind)) return false;

return true;
}

/**
* Extracts a "description" from `@@meta("description", "...")` or `@meta("description", "...")` attributes.
*/
export function getMetaDescription(attributes: readonly AttributeApplication[] | undefined): string | undefined {
if (!attributes) return undefined;
for (const attr of attributes) {
if (attr.name !== '@meta' && attr.name !== '@@meta') continue;
const nameArg = attr.args?.find((a) => a.name === 'name');
if (!nameArg || ExpressionUtils.getLiteralValue(nameArg.value) !== 'description') continue;
const valueArg = attr.args?.find((a) => a.name === 'value');
if (valueArg) {
return ExpressionUtils.getLiteralValue(valueArg.value) as string | undefined;
}
}
return undefined;
}
32 changes: 32 additions & 0 deletions packages/server/src/api/common/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { QueryOptions } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import type { OpenAPIV3_1 } from 'openapi-types';

export type CommonHandlerOptions<Schema extends SchemaDef> = {
/** Query options that affect the behavior of the OpenAPI provider. */
queryOptions?: QueryOptions<Schema>;
};

export type OpenApiSpecOptions = {
/** Spec title. Defaults to 'ZenStack Generated API' */
title?: string;

/** Spec version. Defaults to '1.0.0' */
version?: string;

/** Spec description. */
description?: string;

/** Spec summary. */
summary?: string;
};

/**
* Interface for generating OpenAPI specifications.
*/
export interface OpenApiSpecGenerator {
/**
* Generates an OpenAPI v3.1 specification document.
*/
generateSpec(options?: OpenApiSpecOptions): Promise<OpenAPIV3_1.Document>;
}
1 change: 1 addition & 0 deletions packages/server/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export type { OpenApiSpecGenerator, OpenApiSpecOptions } from './common/types';
export { RestApiHandler, type RestApiHandlerOptions } from './rest';
export { RPCApiHandler, type RPCApiHandlerOptions } from './rpc';
20 changes: 14 additions & 6 deletions packages/server/src/api/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import z from 'zod';
import { fromError } from 'zod-validation-error/v4';
import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types';
import { getProcedureDef, mapProcedureArgs } from '../common/procedures';
import { loggerSchema } from '../common/schemas';
import { loggerSchema, queryOptionsSchema } from '../common/schemas';
import type { CommonHandlerOptions, OpenApiSpecGenerator, OpenApiSpecOptions } from '../common/types';
import { processSuperJsonRequestPayload } from '../common/utils';
import { getZodErrorMessage, log, registerCustomSerializers } from '../utils';
import { RestApiSpecGenerator } from './openapi';

/**
* Options for {@link RestApiHandler}
Expand Down Expand Up @@ -64,7 +66,7 @@ export type RestApiHandlerOptions<Schema extends SchemaDef = SchemaDef> = {
* Mapping from model names to unique field name to be used as resource's ID.
*/
externalIdMapping?: Record<string, string>;
};
} & CommonHandlerOptions<Schema>;

type RelationshipInfo = {
type: string;
Expand Down Expand Up @@ -127,7 +129,7 @@ registerCustomSerializers();
/**
* RESTful-style API request handler (compliant with JSON:API)
*/
export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiHandler<Schema> {
export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiHandler<Schema>, OpenApiSpecGenerator {
// resource serializers
private serializers = new Map<string, Serializer>();

Expand Down Expand Up @@ -298,6 +300,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
urlSegmentCharset: z.string().min(1).optional(),
modelNameMapping: z.record(z.string(), z.string()).optional(),
externalIdMapping: z.record(z.string(), z.string()).optional(),
queryOptions: queryOptionsSchema.optional(),
});
const parseResult = schema.safeParse(options);
if (!parseResult.success) {
Expand Down Expand Up @@ -2060,9 +2063,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
}
} else {
if (op === 'between') {
const parts = value
.split(',')
.map((v) => this.coerce(fieldDef, v));
const parts = value.split(',').map((v) => this.coerce(fieldDef, v));
if (parts.length !== 2) {
throw new InvalidValueError(`"between" expects exactly 2 comma-separated values`);
}
Expand Down Expand Up @@ -2201,4 +2202,11 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
}

//#endregion

async generateSpec(options?: OpenApiSpecOptions) {
const generator = new RestApiSpecGenerator(this.options);
return generator.generateSpec(options);
}
}

export { RestApiSpecGenerator } from './openapi';
Loading
Loading