@beignet/core
Version:
Core framework primitives for Beignet
507 lines (478 loc) • 13.4 kB
text/typescript
import {
assertErrorsAvoidCustomResponseStatuses,
assertResponsesAvoidCatalogErrorStatuses,
mergeCatalogErrors,
responsesFromErrors,
} from "./catalog-errors.js";
import { ContractBuilder } from "./contract-builder.js";
import {
assertValidContractDeprecation,
type ContractDeprecationMeta,
} from "./lifecycle.js";
import { mergeContractMeta } from "./metadata.js";
import { parsePathTemplate } from "./path-template.js";
import type {
ContractErrorResponses,
ContractHeaderSchemas,
ContractMeta,
ContractResponses,
HttpMethod,
MergeContractMeta,
MergedContractErrorResponses,
OmitMetaKeys,
ResponsesFromErrorDefinitions,
StandardSchema,
} from "./types.js";
import { generateContractName } from "./utils.js";
type TrimLeadingSlash<T extends string> = T extends `/${infer Rest}`
? TrimLeadingSlash<Rest>
: T;
type TrimTrailingSlash<T extends string> = T extends "/"
? ""
: T extends `${infer Rest}/`
? TrimTrailingSlash<Rest>
: T;
type NormalizedPrefix<T extends string> = T extends "" | "/"
? ""
: TrimTrailingSlash<T>;
type NormalizedChildPath<T extends string> = TrimTrailingSlash<
TrimLeadingSlash<T>
>;
type JoinPaths<TPrefix extends string, TPath extends string> = string extends
| TPrefix
| TPath
? string
: NormalizedPrefix<TPrefix> extends ""
? TPath
: NormalizedChildPath<TPath> extends ""
? NormalizedPrefix<TPrefix>
: `${NormalizedPrefix<TPrefix>}/${NormalizedChildPath<TPath>}`;
function joinPathPrefix(prefix: string, path: string): string {
const segments = [
...prefix.split("/").filter(Boolean),
...path.split("/").filter(Boolean),
];
return `/${segments.join("/")}`;
}
/**
* Feature-scoped factory for related HTTP contracts.
*
* Contract groups let a feature share a namespace, path prefix, headers,
* responses, errors, and metadata across multiple endpoint contracts. The group
* is immutable: every configuration method returns a new group.
*/
export class ContractGroup<
TSharedResponses extends ContractResponses = Record<never, never>,
TSharedMeta extends ContractMeta = ContractMeta,
TSharedHeaders extends ContractHeaderSchemas = null,
TPathPrefix extends string = "",
> {
private readonly _namespace: string;
private readonly _meta: TSharedMeta;
private readonly _responses: TSharedResponses;
private readonly _headers: TSharedHeaders;
private readonly _pathPrefix: TPathPrefix;
constructor(
state: {
namespace?: string;
meta?: TSharedMeta;
responses?: TSharedResponses;
headers?: TSharedHeaders;
pathPrefix?: TPathPrefix;
} = {} as {
namespace?: string;
meta?: TSharedMeta;
responses?: TSharedResponses;
headers?: TSharedHeaders;
pathPrefix?: TPathPrefix;
},
) {
this._namespace = state.namespace ?? "";
this._meta = state.meta ?? ({} as TSharedMeta);
this._responses = state.responses ?? ({} as TSharedResponses);
this._headers = state.headers ?? (null as TSharedHeaders);
this._pathPrefix = state.pathPrefix ?? ("" as TPathPrefix);
}
/**
* Set the namespace for contracts created from this group.
*
* The namespace prefixes contract names, not paths. Use `prefix(...)` for
* path composition.
*/
namespace(
ns: string,
): ContractGroup<TSharedResponses, TSharedMeta, TSharedHeaders, TPathPrefix> {
return new ContractGroup({
namespace: ns,
meta: this._meta,
responses: this._responses,
headers: this._headers,
pathPrefix: this._pathPrefix,
});
}
/**
* Add a path prefix to contracts created from this group.
*
* Prefixes compose immutably, so `prefix("/api").prefix("/v1")` produces
* paths under `/api/v1`.
*/
prefix<const TNewPrefix extends string>(
pathPrefix: TNewPrefix,
): ContractGroup<
TSharedResponses,
TSharedMeta,
TSharedHeaders,
JoinPaths<TPathPrefix, TNewPrefix>
> {
parsePathTemplate(pathPrefix);
const nextPrefix = joinPathPrefix(this._pathPrefix, pathPrefix);
const normalizedPrefix = nextPrefix === "/" ? "" : nextPrefix;
return new ContractGroup({
namespace: this._namespace,
meta: this._meta,
responses: this._responses,
headers: this._headers,
pathPrefix: normalizedPrefix as JoinPaths<TPathPrefix, TNewPrefix>,
});
}
/**
* Merge shared metadata into contracts created from this group.
*/
meta<TNewMeta extends ContractMeta>(
meta: TNewMeta,
): ContractGroup<
TSharedResponses,
MergeContractMeta<TSharedMeta, TNewMeta>,
TSharedHeaders,
TPathPrefix
> {
return new ContractGroup({
namespace: this._namespace,
meta: mergeContractMeta(this._meta, meta),
responses: this._responses,
headers: this._headers,
pathPrefix: this._pathPrefix,
});
}
/**
* Mark every contract created from this group as deprecated.
*/
deprecated<const TDeprecation extends ContractDeprecationMeta>(
deprecation: TDeprecation,
): ContractGroup<
TSharedResponses,
MergeContractMeta<TSharedMeta, { deprecation: TDeprecation }>,
TSharedHeaders,
TPathPrefix
> {
assertValidContractDeprecation(deprecation, this._namespace || "group");
return new ContractGroup({
namespace: this._namespace,
meta: mergeContractMeta(this._meta, { deprecation }),
responses: this._responses,
headers: this._headers,
pathPrefix: this._pathPrefix,
});
}
/**
* Add shared route-owned response schemas to contracts created from this group.
*
* Framework-owned responses, such as validation or auth hook failures, do not
* need to be declared here.
*/
responses<TNewResponses extends ContractResponses>(
responseSchemas: TNewResponses,
): ContractGroup<
Omit<TSharedResponses, keyof TNewResponses> & TNewResponses,
TSharedMeta,
TSharedHeaders,
TPathPrefix
> {
assertResponsesAvoidCatalogErrorStatuses(this._meta, responseSchemas);
return new ContractGroup({
namespace: this._namespace,
meta: this._meta,
responses: { ...this._responses, ...responseSchemas } as unknown as Omit<
TSharedResponses,
keyof TNewResponses
> &
TNewResponses,
headers: this._headers,
pathPrefix: this._pathPrefix,
});
}
/**
* Declare shared route-owned application errors for contracts in this group.
*
* Catalog errors use Beignet's standard error envelope and remain separate
* from framework-owned errors. Declarations merge with previously declared
* group errors, and contracts created from the group merge these shared
* errors with route-level `.errors()` declarations; later declarations win
* when the same catalog key is declared twice.
*/
errors<TErrorDefs extends ContractErrorResponses>(
errorDefs: TErrorDefs,
): ContractGroup<
Omit<TSharedResponses, keyof ResponsesFromErrorDefinitions<TErrorDefs>> &
ResponsesFromErrorDefinitions<TErrorDefs>,
OmitMetaKeys<TSharedMeta, "errors"> & {
errors: MergedContractErrorResponses<TSharedMeta, TErrorDefs>;
},
TSharedHeaders,
TPathPrefix
> {
const errorResponses = responsesFromErrors(errorDefs);
assertErrorsAvoidCustomResponseStatuses(
this._meta,
this._responses,
errorResponses,
);
return new ContractGroup({
namespace: this._namespace,
meta: {
...this._meta,
errors: mergeCatalogErrors(this._meta, errorDefs),
} as unknown as OmitMetaKeys<TSharedMeta, "errors"> & {
errors: MergedContractErrorResponses<TSharedMeta, TErrorDefs>;
},
responses: { ...this._responses, ...errorResponses } as unknown as Omit<
TSharedResponses,
keyof ResponsesFromErrorDefinitions<TErrorDefs>
> &
ResponsesFromErrorDefinitions<TErrorDefs>,
headers: this._headers,
pathPrefix: this._pathPrefix,
});
}
/**
* Add a shared request header schema to contracts created from this group.
*/
headers<TNewHeaders extends StandardSchema>(
schema: TNewHeaders,
): ContractGroup<
TSharedResponses,
TSharedMeta,
TSharedHeaders extends readonly StandardSchema[]
? readonly [...TSharedHeaders, TNewHeaders]
: TSharedHeaders extends StandardSchema
? readonly [TSharedHeaders, TNewHeaders]
: readonly [TNewHeaders],
TPathPrefix
> {
const existingHeaders = this._headers
? Array.isArray(this._headers)
? this._headers
: [this._headers]
: [];
return new ContractGroup({
namespace: this._namespace,
meta: this._meta,
responses: this._responses,
headers: [
...existingHeaders,
schema,
] as unknown as TSharedHeaders extends readonly StandardSchema[]
? readonly [...TSharedHeaders, TNewHeaders]
: TSharedHeaders extends StandardSchema
? readonly [TSharedHeaders, TNewHeaders]
: readonly [TNewHeaders],
pathPrefix: this._pathPrefix,
});
}
/**
* Create a GET contract under this group.
*/
get<const TPath extends string>(
path: TPath,
name?: string,
): ContractBuilder<
"GET",
null,
null,
null,
TSharedHeaders,
TSharedResponses,
TSharedMeta,
JoinPaths<TPathPrefix, TPath>
> {
return this.createBuilder("GET", path, name);
}
/**
* Create a POST contract under this group.
*/
post<const TPath extends string>(
path: TPath,
name?: string,
): ContractBuilder<
"POST",
null,
null,
null,
TSharedHeaders,
TSharedResponses,
TSharedMeta,
JoinPaths<TPathPrefix, TPath>
> {
return this.createBuilder("POST", path, name);
}
/**
* Create a PUT contract under this group.
*/
put<const TPath extends string>(
path: TPath,
name?: string,
): ContractBuilder<
"PUT",
null,
null,
null,
TSharedHeaders,
TSharedResponses,
TSharedMeta,
JoinPaths<TPathPrefix, TPath>
> {
return this.createBuilder("PUT", path, name);
}
/**
* Create a PATCH contract under this group.
*/
patch<const TPath extends string>(
path: TPath,
name?: string,
): ContractBuilder<
"PATCH",
null,
null,
null,
TSharedHeaders,
TSharedResponses,
TSharedMeta,
JoinPaths<TPathPrefix, TPath>
> {
return this.createBuilder("PATCH", path, name);
}
/**
* Create a DELETE contract under this group.
*/
delete<const TPath extends string>(
path: TPath,
name?: string,
): ContractBuilder<
"DELETE",
null,
null,
null,
TSharedHeaders,
TSharedResponses,
TSharedMeta,
JoinPaths<TPathPrefix, TPath>
> {
return this.createBuilder("DELETE", path, name);
}
/**
* Create a HEAD contract under this group.
*/
head<const TPath extends string>(
path: TPath,
name?: string,
): ContractBuilder<
"HEAD",
null,
null,
null,
TSharedHeaders,
TSharedResponses,
TSharedMeta,
JoinPaths<TPathPrefix, TPath>
> {
return this.createBuilder("HEAD", path, name);
}
/**
* Create an OPTIONS contract under this group.
*/
options<const TPath extends string>(
path: TPath,
name?: string,
): ContractBuilder<
"OPTIONS",
null,
null,
null,
TSharedHeaders,
TSharedResponses,
TSharedMeta,
JoinPaths<TPathPrefix, TPath>
> {
return this.createBuilder("OPTIONS", path, name);
}
/**
* Internal helper to create a contract builder with shared config
*/
private createBuilder<TMethod extends HttpMethod, const TPath extends string>(
method: TMethod,
path: TPath,
name?: string,
): ContractBuilder<
TMethod,
null,
null,
null,
TSharedHeaders,
TSharedResponses,
TSharedMeta,
JoinPaths<TPathPrefix, TPath>
> {
parsePathTemplate(path);
const fullPath = (
this._pathPrefix ? joinPathPrefix(this._pathPrefix, path) : path
) as JoinPaths<TPathPrefix, TPath>;
parsePathTemplate(fullPath);
const contractName = name || generateContractName(method, fullPath);
const fullName = this._namespace
? `${this._namespace}.${contractName}`
: contractName;
return new ContractBuilder({
kind: "http",
name: fullName,
namespace: this._namespace || undefined,
localName: contractName,
method,
path: fullPath,
pathParams: null,
query: null,
headers: this._headers,
body: null,
responses: { ...this._responses } as unknown as TSharedResponses,
metadata: { ...this._meta } as unknown as TSharedMeta,
});
}
}
/**
* Create a new feature contract group.
*
* Start here for most feature HTTP surfaces, then add a namespace and path
* prefix before defining individual contracts.
*
* @example
* ```ts
* const todos = defineContractGroup()
* .namespace("todos")
* .prefix("/api/todos")
* .meta({ auth: "required" })
* .responses({
* 401: z.object({ message: z.literal("Unauthorized") }),
* });
*
* const getTodo = todos.get("/:id")...
* ```
*
* @returns An empty immutable contract group.
*/
export function defineContractGroup(): ContractGroup<
Record<never, never>,
ContractMeta,
null,
""
> {
return new ContractGroup();
}