Initial package implementation: operations registry, call protocol, and adapters
Extracted from alkhub_ts packages/core/operations/ and packages/core/mcp/. - Runtime-agnostic (injected fs/env deps, no Deno globals) - Direct @logtape/logtape import instead of logger wrapper - PendingRequestMap with pubsub-wired call protocol - Peer-dep isolation for MCP adapter (sub-path export) - Schema const naming convention (XSchema + X type alias) - 68 tests passing, build + lint + test all green
This commit is contained in:
249
src/call.ts
Normal file
249
src/call.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { Type, type Static } from "@alkdev/typebox";
|
||||
import { createPubSub, type PubSub } from "@alkdev/pubsub";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import { OperationRegistry } from "./registry.js";
|
||||
import { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
||||
import { validateOrThrow } from "./validation.js";
|
||||
import type { IOperationDefinition, Identity, OperationContext, AccessControl } from "./types.js";
|
||||
|
||||
const logger = getLogger("operations:call");
|
||||
|
||||
export const CallEventSchema = {
|
||||
"call.requested": Type.Object({
|
||||
requestId: Type.String(),
|
||||
operationId: Type.String(),
|
||||
input: Type.Unknown(),
|
||||
parentRequestId: Type.Optional(Type.String()),
|
||||
deadline: Type.Optional(Type.Number()),
|
||||
identity: Type.Optional(Type.Object({
|
||||
id: Type.String(),
|
||||
scopes: Type.Array(Type.String()),
|
||||
resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))),
|
||||
})),
|
||||
}),
|
||||
"call.responded": Type.Object({
|
||||
requestId: Type.String(),
|
||||
output: Type.Unknown(),
|
||||
}),
|
||||
"call.aborted": Type.Object({
|
||||
requestId: Type.String(),
|
||||
}),
|
||||
"call.error": Type.Object({
|
||||
requestId: Type.String(),
|
||||
code: Type.String(),
|
||||
message: Type.String(),
|
||||
details: Type.Optional(Type.Unknown()),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
export type CallRequestedEvent = Static<typeof CallEventSchema["call.requested"]>;
|
||||
export type CallRespondedEvent = Static<typeof CallEventSchema["call.responded"]>;
|
||||
export type CallAbortedEvent = Static<typeof CallEventSchema["call.aborted"]>;
|
||||
export type CallErrorEvent = Static<typeof CallEventSchema["call.error"]>;
|
||||
export type CallEventMapValue = CallRequestedEvent | CallRespondedEvent | CallAbortedEvent | CallErrorEvent;
|
||||
|
||||
export const CallEventMap = CallEventSchema;
|
||||
|
||||
type CallPubSubMap = {
|
||||
"call.requested": [CallRequestedEvent];
|
||||
"call.responded": [CallRespondedEvent];
|
||||
"call.aborted": [CallAbortedEvent];
|
||||
"call.error": [CallErrorEvent];
|
||||
};
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
deadline?: number;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface CallHandlerConfig {
|
||||
registry: OperationRegistry;
|
||||
eventTarget?: EventTarget;
|
||||
}
|
||||
|
||||
export type CallHandler = (event: CallRequestedEvent) => Promise<void>;
|
||||
|
||||
export class PendingRequestMap {
|
||||
private requests = new Map<string, PendingRequest>();
|
||||
private pubsub: PubSub<CallPubSubMap>;
|
||||
|
||||
constructor(eventTarget?: EventTarget) {
|
||||
this.pubsub = createPubSub<CallPubSubMap>(
|
||||
eventTarget ? { eventTarget: eventTarget as any } : undefined
|
||||
);
|
||||
this.setupSubscriptions();
|
||||
}
|
||||
|
||||
private setupSubscriptions(): void {
|
||||
const respondedIter = this.pubsub.subscribe("call.responded");
|
||||
(async () => {
|
||||
for await (const event of respondedIter) {
|
||||
const responded = event as CallRespondedEvent;
|
||||
const pending = this.requests.get(responded.requestId);
|
||||
if (pending) {
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
this.requests.delete(responded.requestId);
|
||||
pending.resolve(responded.output);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const errorIter = this.pubsub.subscribe("call.error");
|
||||
(async () => {
|
||||
for await (const event of errorIter) {
|
||||
const err = event as CallErrorEvent;
|
||||
const pending = this.requests.get(err.requestId);
|
||||
if (pending) {
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
this.requests.delete(err.requestId);
|
||||
pending.reject(new CallError(err.code, err.message, err.details));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const abortedIter = this.pubsub.subscribe("call.aborted");
|
||||
(async () => {
|
||||
for await (const event of abortedIter) {
|
||||
const aborted = event as CallAbortedEvent;
|
||||
const pending = this.requests.get(aborted.requestId);
|
||||
if (pending) {
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
this.requests.delete(aborted.requestId);
|
||||
pending.reject(new CallError(InfrastructureErrorCode.ABORTED, `Request ${aborted.requestId} was aborted`));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async call(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
options?: { parentRequestId?: string; deadline?: number; identity?: Identity },
|
||||
): Promise<unknown> {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const pending: PendingRequest = { resolve, reject };
|
||||
|
||||
if (options?.deadline) {
|
||||
pending.deadline = options.deadline;
|
||||
pending.timer = setTimeout(() => {
|
||||
this.requests.delete(requestId);
|
||||
reject(new CallError(InfrastructureErrorCode.TIMEOUT, `Request ${requestId} timed out`, { deadline: options.deadline }));
|
||||
}, options.deadline - Date.now());
|
||||
}
|
||||
|
||||
this.requests.set(requestId, pending);
|
||||
|
||||
this.pubsub.publish("call.requested", {
|
||||
requestId,
|
||||
operationId,
|
||||
input,
|
||||
parentRequestId: options?.parentRequestId,
|
||||
deadline: options?.deadline,
|
||||
identity: options?.identity,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
respond(requestId: string, output: unknown): void {
|
||||
this.pubsub.publish("call.responded", {
|
||||
requestId,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
emitError(requestId: string, code: string, message: string, details?: unknown): void {
|
||||
this.pubsub.publish("call.error", {
|
||||
requestId,
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
abort(requestId: string): void {
|
||||
const pending = this.requests.get(requestId);
|
||||
if (pending) {
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
this.requests.delete(requestId);
|
||||
this.pubsub.publish("call.aborted", { requestId });
|
||||
pending.reject(new CallError(InfrastructureErrorCode.ABORTED, `Request ${requestId} was aborted`));
|
||||
}
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.requests.size;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCallHandler(config: CallHandlerConfig): CallHandler {
|
||||
const { registry } = config;
|
||||
|
||||
return async (event: CallRequestedEvent): Promise<void> => {
|
||||
const { requestId, operationId, input, identity } = event;
|
||||
|
||||
try {
|
||||
const operation = registry.get(operationId);
|
||||
|
||||
if (!operation) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.OPERATION_NOT_FOUND,
|
||||
`Operation not found: ${operationId}`,
|
||||
{ operationId },
|
||||
);
|
||||
}
|
||||
|
||||
const accessControl: AccessControl = operation.accessControl as AccessControl;
|
||||
|
||||
if (identity && !checkAccess(accessControl, identity)) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.ACCESS_DENIED,
|
||||
`Access denied for operation: ${operationId}`,
|
||||
{ requiredScopes: accessControl.requiredScopes },
|
||||
);
|
||||
}
|
||||
|
||||
const context: OperationContext = {
|
||||
requestId,
|
||||
parentRequestId: event.parentRequestId,
|
||||
identity,
|
||||
};
|
||||
|
||||
validateOrThrow(operation.inputSchema, input, `Input validation for ${operationId}`);
|
||||
|
||||
await operation.handler(input, context);
|
||||
|
||||
} catch (error) {
|
||||
const callError = mapError(error);
|
||||
throw callError;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function checkAccess(accessControl: AccessControl, identity: Identity): boolean {
|
||||
const { requiredScopes, requiredScopesAny, resourceType, resourceAction } = accessControl;
|
||||
|
||||
if (requiredScopes.length > 0) {
|
||||
const hasAll = requiredScopes.every((scope: string) => identity.scopes.includes(scope));
|
||||
if (!hasAll) return false;
|
||||
}
|
||||
|
||||
if (requiredScopesAny && requiredScopesAny.length > 0) {
|
||||
const hasAny = requiredScopesAny.some((scope: string) => identity.scopes.includes(scope));
|
||||
if (!hasAny) return false;
|
||||
}
|
||||
|
||||
if (resourceType && resourceAction && identity.resources) {
|
||||
for (const [key, actions] of Object.entries(identity.resources)) {
|
||||
if (key.startsWith(`${resourceType}:`) && actions.includes(resourceAction)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
56
src/env.ts
Normal file
56
src/env.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { OperationType } from "./types.js";
|
||||
import type { OperationContext, OperationEnv } from "./types.js";
|
||||
import type { OperationRegistry } from "./registry.js";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
|
||||
const logger = getLogger("operations:env");
|
||||
|
||||
export interface PendingRequestMap {
|
||||
call(operationId: string, input: unknown, options?: { parentRequestId?: string; identity?: unknown }): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface EnvOptions {
|
||||
registry: OperationRegistry;
|
||||
context: OperationContext;
|
||||
allowedNamespaces?: string[];
|
||||
callMap?: PendingRequestMap;
|
||||
}
|
||||
|
||||
export function buildEnv(options: EnvOptions): OperationEnv {
|
||||
const { registry, context, allowedNamespaces, callMap } = options;
|
||||
const operations = registry.list();
|
||||
|
||||
const namespaces: OperationEnv = {};
|
||||
|
||||
for (const operation of operations) {
|
||||
if (allowedNamespaces && !allowedNamespaces.includes(operation.namespace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (operation.type === OperationType.SUBSCRIPTION) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!namespaces[operation.namespace]) {
|
||||
namespaces[operation.namespace] = {};
|
||||
}
|
||||
|
||||
const operationId = `${operation.namespace}.${operation.name}`;
|
||||
|
||||
if (callMap) {
|
||||
namespaces[operation.namespace][operation.name] = async (input: unknown) => {
|
||||
logger.debug(`Call protocol: ${operationId}`);
|
||||
return await callMap.call(operationId, input, {
|
||||
parentRequestId: context.requestId,
|
||||
});
|
||||
};
|
||||
} else {
|
||||
namespaces[operation.namespace][operation.name] = async (input: unknown) => {
|
||||
logger.debug(`Executing: ${operationId}`);
|
||||
return await registry.execute(operationId, input, context);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return namespaces;
|
||||
}
|
||||
51
src/error.ts
Normal file
51
src/error.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export enum InfrastructureErrorCode {
|
||||
OPERATION_NOT_FOUND = "OPERATION_NOT_FOUND",
|
||||
ACCESS_DENIED = "ACCESS_DENIED",
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR",
|
||||
TIMEOUT = "TIMEOUT",
|
||||
ABORTED = "ABORTED",
|
||||
EXECUTION_ERROR = "EXECUTION_ERROR",
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
}
|
||||
|
||||
export type CallErrorCode = InfrastructureErrorCode | string;
|
||||
|
||||
export class CallError extends Error {
|
||||
readonly code: CallErrorCode;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(code: CallErrorCode, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = "CallError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function mapError(
|
||||
error: unknown,
|
||||
errorSchemas?: { code: string; schema: unknown }[],
|
||||
): CallError {
|
||||
if (error instanceof CallError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (errorSchemas) {
|
||||
const message = error.message;
|
||||
for (const schema of errorSchemas) {
|
||||
if (message.includes(schema.code)) {
|
||||
return new CallError(schema.code, message, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new CallError(InfrastructureErrorCode.EXECUTION_ERROR, error.message, error);
|
||||
}
|
||||
|
||||
return new CallError(
|
||||
InfrastructureErrorCode.UNKNOWN_ERROR,
|
||||
String(error),
|
||||
{ raw: String(error) },
|
||||
);
|
||||
}
|
||||
151
src/from_mcp.ts
Normal file
151
src/from_mcp.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { IOperationDefinition } from "./types.js";
|
||||
import { OperationType } from "./types.js";
|
||||
import { Type, type TSchema } from "@alkdev/typebox";
|
||||
import { FromSchema } from "./from_schema.js";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
|
||||
const logger = getLogger("operations:mcp");
|
||||
|
||||
export interface MCPClientConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MCPClientWrapper {
|
||||
name: string;
|
||||
client: unknown;
|
||||
tools: IOperationDefinition[];
|
||||
}
|
||||
|
||||
export async function createMCPClient(
|
||||
name: string,
|
||||
config: MCPClientConfig,
|
||||
): Promise<MCPClientWrapper> {
|
||||
logger.info(`Creating MCP client for: ${name}`);
|
||||
|
||||
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
||||
const client = new Client({ name: `alkdev-${name}`, version: "1.0.0" });
|
||||
|
||||
let transport: any;
|
||||
|
||||
if (config.url) {
|
||||
const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
||||
const url = new URL(config.url);
|
||||
transport = new StreamableHTTPClientTransport(url, {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
});
|
||||
} else if (config.command) {
|
||||
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
|
||||
transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args || [],
|
||||
env: config.env as Record<string, string> | undefined,
|
||||
cwd: config.cwd,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Invalid MCP server config for ${name}: must have either 'url' or 'command'`);
|
||||
}
|
||||
|
||||
await client.connect(transport);
|
||||
logger.info(`Connected to MCP server: ${name}`);
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
const operations: IOperationDefinition[] = toolsResult.tools.map((tool: { name: string; description?: string; inputSchema: unknown }) => {
|
||||
return {
|
||||
name: tool.name,
|
||||
namespace: name,
|
||||
version: "1.0.0",
|
||||
type: OperationType.MUTATION,
|
||||
description: tool.description || "",
|
||||
tags: [],
|
||||
inputSchema: FromSchema(tool.inputSchema) as TSchema,
|
||||
outputSchema: Type.Unknown(),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: async (input: unknown) => {
|
||||
logger.debug(`Calling MCP tool: ${name}.${tool.name}`);
|
||||
const result = await client.callTool({
|
||||
name: tool.name,
|
||||
arguments: input as Record<string, unknown>,
|
||||
});
|
||||
|
||||
if (result.isError) {
|
||||
throw new Error(`MCP tool error: ${JSON.stringify(result.content)}`);
|
||||
}
|
||||
|
||||
return result.content;
|
||||
},
|
||||
} satisfies IOperationDefinition;
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
client,
|
||||
tools: operations,
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeMCPClient(wrapper: MCPClientWrapper): Promise<void> {
|
||||
logger.info(`Closing MCP client: ${wrapper.name}`);
|
||||
const client = wrapper.client as any;
|
||||
if (client && typeof client.close === "function") {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class MCPClientLoader {
|
||||
private clients: Map<string, MCPClientWrapper> = new Map();
|
||||
|
||||
async load(config: Record<string, MCPClientConfig>): Promise<MCPClientWrapper[]> {
|
||||
logger.info(`Loading ${Object.keys(config).length} MCP servers`);
|
||||
|
||||
const wrappers: MCPClientWrapper[] = [];
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(config)) {
|
||||
try {
|
||||
const wrapper = await createMCPClient(name, serverConfig);
|
||||
this.clients.set(name, wrapper);
|
||||
wrappers.push(wrapper);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load MCP server ${name}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return wrappers;
|
||||
}
|
||||
|
||||
getClient(name: string): MCPClientWrapper | undefined {
|
||||
return this.clients.get(name);
|
||||
}
|
||||
|
||||
getAllWrappers(): MCPClientWrapper[] {
|
||||
return Array.from(this.clients.values());
|
||||
}
|
||||
|
||||
getAllOperations(): IOperationDefinition[] {
|
||||
const allOps: IOperationDefinition[] = [];
|
||||
for (const wrapper of this.clients.values()) {
|
||||
for (const op of wrapper.tools) {
|
||||
allOps.push(op);
|
||||
}
|
||||
}
|
||||
return allOps;
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
logger.info(`Closing ${this.clients.size} MCP clients`);
|
||||
|
||||
const closePromises = Array.from(this.clients.values()).map((wrapper) =>
|
||||
closeMCPClient(wrapper).catch((error) => {
|
||||
logger.error(`Error closing MCP client ${wrapper.name}: ${error}`);
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(closePromises);
|
||||
this.clients.clear();
|
||||
}
|
||||
}
|
||||
339
src/from_openapi.ts
Normal file
339
src/from_openapi.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import * as Type from "@alkdev/typebox";
|
||||
import { FromSchema } from "./from_schema.js";
|
||||
import { OperationType, type IOperationDefinition, type OperationHandler, type OperationContext } from "./types.js";
|
||||
|
||||
export interface OpenAPIFS {
|
||||
readFile(path: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface OpenAPISpec {
|
||||
openapi?: string;
|
||||
swagger?: string;
|
||||
info: { title: string; version: string; description?: string };
|
||||
paths: Record<string, Record<string, OpenAPIOperation>>;
|
||||
components?: { schemas?: Record<string, unknown> };
|
||||
definitions?: Record<string, unknown>;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export interface OpenAPIOperation {
|
||||
operationId?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
parameters?: OpenAPIParameter[];
|
||||
requestBody?: {
|
||||
content?: Record<string, { schema?: unknown }>;
|
||||
};
|
||||
responses?: Record<string, { content?: Record<string, { schema?: unknown }>; description?: string }>;
|
||||
}
|
||||
|
||||
export interface OpenAPIParameter {
|
||||
name: string;
|
||||
in: "path" | "query" | "header" | "cookie";
|
||||
required?: boolean;
|
||||
schema?: unknown;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface HTTPServiceConfig {
|
||||
namespace: string;
|
||||
baseUrl: string;
|
||||
headers?: Record<string, string>;
|
||||
auth?: {
|
||||
type: "bearer" | "apiKey" | "basic";
|
||||
token?: string;
|
||||
headerName?: string;
|
||||
prefix?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
function resolveRef(spec: OpenAPISpec, ref: string): unknown {
|
||||
if (!ref.startsWith("#/")) {
|
||||
throw new Error(`External refs not supported: ${ref}`);
|
||||
}
|
||||
|
||||
const parts = ref.slice(2).split("/");
|
||||
let current: unknown = spec;
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof current !== "object" || current === null) {
|
||||
throw new Error(`Cannot resolve ref: ${ref}`);
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function resolveRefsRecursive(
|
||||
spec: OpenAPISpec,
|
||||
schema: unknown,
|
||||
visited: Set<unknown> = new Set(),
|
||||
): unknown {
|
||||
if (typeof schema !== "object" || schema === null) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
if (visited.has(schema)) {
|
||||
return { type: "object", description: "[circular reference]" };
|
||||
}
|
||||
|
||||
visited.add(schema);
|
||||
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((item) => resolveRefsRecursive(spec, item, visited));
|
||||
}
|
||||
|
||||
const obj = schema as Record<string, unknown>;
|
||||
|
||||
if (obj.$ref && typeof obj.$ref === "string") {
|
||||
const resolved = resolveRef(spec, obj.$ref);
|
||||
return resolveRefsRecursive(spec, resolved, visited);
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = resolveRefsRecursive(spec, value, visited);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildInputSchema(
|
||||
spec: OpenAPISpec,
|
||||
operation: OpenAPIOperation,
|
||||
): Type.TSchema {
|
||||
const properties: Record<string, Type.TSchema> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
if (operation.parameters) {
|
||||
for (const param of operation.parameters) {
|
||||
const paramSchema = param.schema
|
||||
? FromSchema(resolveRefsRecursive(spec, param.schema) as Record<string, unknown>)
|
||||
: Type.String();
|
||||
|
||||
properties[param.name] = paramSchema;
|
||||
|
||||
if (param.required) {
|
||||
required.push(param.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operation.requestBody?.content?.["application/json"]?.schema) {
|
||||
const bodySchema = resolveRefsRecursive(
|
||||
spec,
|
||||
operation.requestBody.content["application/json"].schema,
|
||||
) as Record<string, unknown>;
|
||||
properties.body = FromSchema(bodySchema);
|
||||
required.push("body");
|
||||
}
|
||||
|
||||
if (Object.keys(properties).length === 0) {
|
||||
return Type.Object({});
|
||||
}
|
||||
|
||||
const propsWithOptional: Record<string, Type.TSchema> = {};
|
||||
for (const [key, schema] of Object.entries(properties)) {
|
||||
if (required.includes(key)) {
|
||||
propsWithOptional[key] = schema;
|
||||
} else {
|
||||
propsWithOptional[key] = Type.Optional(schema);
|
||||
}
|
||||
}
|
||||
|
||||
return Type.Object(propsWithOptional);
|
||||
}
|
||||
|
||||
function buildOutputSchema(
|
||||
spec: OpenAPISpec,
|
||||
operation: OpenAPIOperation,
|
||||
): Type.TSchema {
|
||||
const successResponse = operation.responses?.["200"] || operation.responses?.["201"];
|
||||
|
||||
if (!successResponse?.content) {
|
||||
return Type.Unknown();
|
||||
}
|
||||
|
||||
const jsonSchema = successResponse.content["application/json"]?.schema;
|
||||
if (!jsonSchema) {
|
||||
const eventStreamSchema = successResponse.content["text/event-stream"]?.schema;
|
||||
if (eventStreamSchema) {
|
||||
return FromSchema(resolveRefsRecursive(spec, eventStreamSchema) as Record<string, unknown>);
|
||||
}
|
||||
return Type.Unknown();
|
||||
}
|
||||
|
||||
return FromSchema(resolveRefsRecursive(spec, jsonSchema) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function detectOperationType(method: string, operation: OpenAPIOperation): OperationType {
|
||||
const successResponse = operation.responses?.["200"] || operation.responses?.["201"];
|
||||
|
||||
if (successResponse?.content && "text/event-stream" in successResponse.content) {
|
||||
return OperationType.SUBSCRIPTION;
|
||||
}
|
||||
|
||||
if (method.toLowerCase() === "get") {
|
||||
return OperationType.QUERY;
|
||||
}
|
||||
|
||||
return OperationType.MUTATION;
|
||||
}
|
||||
|
||||
function normalizeOperationId(op: OpenAPIOperation, method: string, path: string): string {
|
||||
if (op.operationId) {
|
||||
return op.operationId;
|
||||
}
|
||||
|
||||
const pathParts = path.split("/").filter((p) => p && !p.startsWith("{"));
|
||||
const baseName = pathParts.join("_") || "root";
|
||||
return `${method}_${baseName}`;
|
||||
}
|
||||
|
||||
function getAuthHeaders(config: HTTPServiceConfig): Record<string, string> {
|
||||
const headers: Record<string, string> = { ...config.headers };
|
||||
|
||||
if (config.auth) {
|
||||
const token = config.auth.token;
|
||||
|
||||
if (token) {
|
||||
switch (config.auth.type) {
|
||||
case "bearer":
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
break;
|
||||
case "apiKey":
|
||||
const headerName = config.auth.headerName || "X-API-Key";
|
||||
const prefix = config.auth.prefix || "";
|
||||
headers[headerName] = prefix + token;
|
||||
break;
|
||||
case "basic":
|
||||
headers["Authorization"] = `Basic ${token}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function createHTTPOperation(
|
||||
spec: OpenAPISpec,
|
||||
operation: OpenAPIOperation,
|
||||
method: string,
|
||||
path: string,
|
||||
config: HTTPServiceConfig,
|
||||
): IOperationDefinition {
|
||||
const operationId = normalizeOperationId(operation, method, path);
|
||||
const opType = detectOperationType(method, operation);
|
||||
const authHeaders = getAuthHeaders(config);
|
||||
|
||||
const handler: OperationHandler<unknown, unknown, OperationContext> = async (input: unknown, context: OperationContext) => {
|
||||
const inputObj = (input as Record<string, unknown>) || {};
|
||||
|
||||
let urlPath = path;
|
||||
const queryParams: Record<string, string> = {};
|
||||
let body: unknown = undefined;
|
||||
|
||||
for (const [key, value] of Object.entries(inputObj)) {
|
||||
if (path.includes(`{${key}}`)) {
|
||||
urlPath = urlPath.replace(`{${key}}`, encodeURIComponent(String(value)));
|
||||
} else if (key === "body") {
|
||||
body = value;
|
||||
} else {
|
||||
queryParams[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(config.baseUrl + urlPath);
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...authHeaders,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: config.timeout ? AbortSignal.timeout(config.timeout) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
return response.json();
|
||||
} else if (contentType.includes("text/")) {
|
||||
return response.text();
|
||||
} else {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: operationId,
|
||||
namespace: config.namespace,
|
||||
version: "1.0.0",
|
||||
type: opType,
|
||||
description: operation.description || operation.summary || `${method.toUpperCase()} ${path}`,
|
||||
tags: operation.tags,
|
||||
inputSchema: buildInputSchema(spec, operation),
|
||||
outputSchema: buildOutputSchema(spec, operation),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler,
|
||||
_meta: {
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: operation.summary,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function FromOpenAPI(spec: OpenAPISpec, config: HTTPServiceConfig): IOperationDefinition[] {
|
||||
const operations: IOperationDefinition[] = [];
|
||||
const basePath = spec.basePath || "";
|
||||
|
||||
for (const [path, methods] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
if (!["get", "post", "put", "patch", "delete"].includes(method)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!operation || typeof operation !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const op = operation as OpenAPIOperation;
|
||||
operations.push(createHTTPOperation(spec, op, method, basePath + path, config));
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
export async function FromOpenAPIFile(path: string, config: HTTPServiceConfig, fs?: OpenAPIFS): Promise<IOperationDefinition[]> {
|
||||
let content: string;
|
||||
if (fs) {
|
||||
content = await fs.readFile(path);
|
||||
} else {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
content = await readFile(path, "utf-8");
|
||||
}
|
||||
const spec = JSON.parse(content) as OpenAPISpec;
|
||||
return FromOpenAPI(spec, config);
|
||||
}
|
||||
|
||||
export async function FromOpenAPIUrl(url: string, config: HTTPServiceConfig): Promise<IOperationDefinition[]> {
|
||||
const response = await fetch(url);
|
||||
const spec = await response.json() as OpenAPISpec;
|
||||
return FromOpenAPI(spec, config);
|
||||
}
|
||||
115
src/from_schema.ts
Normal file
115
src/from_schema.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as Type from "@alkdev/typebox";
|
||||
|
||||
const IsExact = (value: unknown, expect: unknown) => value === expect;
|
||||
const IsSValue = (value: unknown): value is SValue =>
|
||||
Type.ValueGuard.IsString(value) || Type.ValueGuard.IsNumber(value) || Type.ValueGuard.IsBoolean(value);
|
||||
const IsSEnum = (value: unknown): value is SEnum =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.enum) && value.enum.every((v) => IsSValue(v));
|
||||
const IsSAllOf = (value: unknown): value is SAllOf => Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.allOf);
|
||||
const IsSAnyOf = (value: unknown): value is SAnyOf => Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.anyOf);
|
||||
const IsSOneOf = (value: unknown): value is SOneOf => Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.oneOf);
|
||||
const IsSTuple = (value: unknown): value is STuple =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "array") && Type.ValueGuard.IsArray(value.items);
|
||||
const IsSArray = (value: unknown): value is SArray =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "array") && !Type.ValueGuard.IsArray(value.items) && Type.ValueGuard.IsObject(value.items);
|
||||
const IsSConst = (value: unknown): value is SConst => Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsObject(value["const"]);
|
||||
const IsSString = (value: unknown): value is SString => Type.ValueGuard.IsObject(value) && IsExact(value.type, "string");
|
||||
const IsSRef = (value: unknown): value is SRef => Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsString(value.$ref);
|
||||
const IsSNumber = (value: unknown): value is SNumber => Type.ValueGuard.IsObject(value) && IsExact(value.type, "number");
|
||||
const IsSInteger = (value: unknown): value is SInteger => Type.ValueGuard.IsObject(value) && IsExact(value.type, "integer");
|
||||
const IsSBoolean = (value: unknown): value is SBoolean => Type.ValueGuard.IsObject(value) && IsExact(value.type, "boolean");
|
||||
const IsSNull = (value: unknown): value is SNull => Type.ValueGuard.IsObject(value) && IsExact(value.type, "null");
|
||||
const IsSProperties = (value: unknown): value is SProperties => Type.ValueGuard.IsObject(value);
|
||||
const IsSObject = (value: unknown): value is SObject =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "object") &&
|
||||
IsSProperties(value.properties) &&
|
||||
(value.required === undefined || (Type.ValueGuard.IsArray(value.required) && value.required.every((v: unknown) => Type.ValueGuard.IsString(v))));
|
||||
|
||||
type SValue = string | number | boolean;
|
||||
type SEnum = Readonly<{ enum: readonly SValue[] }>;
|
||||
type SAllOf = Readonly<{ allOf: readonly unknown[] }>;
|
||||
type SAnyOf = Readonly<{ anyOf: readonly unknown[] }>;
|
||||
type SOneOf = Readonly<{ oneOf: readonly unknown[] }>;
|
||||
type SProperties = Record<PropertyKey, unknown>;
|
||||
type SObject = Readonly<{ type: "object"; properties: SProperties; required?: readonly string[] }>;
|
||||
type STuple = Readonly<{ type: "array"; items: readonly unknown[] }>;
|
||||
type SArray = Readonly<{ type: "array"; items: unknown }>;
|
||||
type SConst = Readonly<{ const: SValue }>;
|
||||
type SRef = Readonly<{ $ref: string }>;
|
||||
type SString = Readonly<{ type: "string" }>;
|
||||
type SNumber = Readonly<{ type: "number" }>;
|
||||
type SInteger = Readonly<{ type: "integer" }>;
|
||||
type SBoolean = Readonly<{ type: "boolean" }>;
|
||||
type SNull = Readonly<{ type: "null" }>;
|
||||
|
||||
function FromRest<T extends readonly unknown[]>(T: T): Type.TSchema[] {
|
||||
return T.map((L) => FromSchema(L)) as never;
|
||||
}
|
||||
|
||||
function FromEnumRest<T extends readonly SValue[]>(T: T): Type.TSchema[] {
|
||||
return T.map((L) => Type.Literal(L)) as never;
|
||||
}
|
||||
|
||||
function FromAllOf<T extends SAllOf>(T: T): Type.TSchema {
|
||||
return Type.IntersectEvaluated(FromRest(T.allOf), T);
|
||||
}
|
||||
|
||||
function FromAnyOf<T extends SAnyOf>(T: T): Type.TSchema {
|
||||
return Type.UnionEvaluated(FromRest(T.anyOf), T);
|
||||
}
|
||||
|
||||
function FromOneOf<T extends SOneOf>(T: T): Type.TSchema {
|
||||
return Type.UnionEvaluated(FromRest(T.oneOf), T);
|
||||
}
|
||||
|
||||
function FromEnum<T extends SEnum>(T: T): Type.TSchema {
|
||||
return Type.UnionEvaluated(FromEnumRest(T.enum));
|
||||
}
|
||||
|
||||
function FromTuple<T extends STuple>(T: T): Type.TSchema {
|
||||
return Type.Tuple(FromRest(T.items), T) as never;
|
||||
}
|
||||
|
||||
function FromArray<T extends SArray>(T: T): Type.TSchema {
|
||||
return Type.Array(FromSchema(T.items), T) as never;
|
||||
}
|
||||
|
||||
function FromConst<T extends SConst>(T: T): Type.TSchema {
|
||||
return Type.Literal(T.const, T);
|
||||
}
|
||||
|
||||
function FromRef<T extends SRef>(T: T): Type.TSchema {
|
||||
return Type.Ref(T.$ref);
|
||||
}
|
||||
|
||||
function FromObject<T extends SObject>(T: T): Type.TSchema {
|
||||
const properties = globalThis.Object.getOwnPropertyNames(T.properties).reduce(
|
||||
(Acc, K) => {
|
||||
return {
|
||||
...Acc,
|
||||
[K]: T.required && T.required.includes(K) ? FromSchema(T.properties[K]) : Type.Optional(FromSchema(T.properties[K])),
|
||||
};
|
||||
},
|
||||
{} as Type.TProperties,
|
||||
);
|
||||
return Type.Object(properties, T) as never;
|
||||
}
|
||||
|
||||
export function FromSchema<T>(T: T): Type.TSchema {
|
||||
if (IsSAllOf(T)) return FromAllOf(T);
|
||||
if (IsSAnyOf(T)) return FromAnyOf(T);
|
||||
if (IsSOneOf(T)) return FromOneOf(T);
|
||||
if (IsSEnum(T)) return FromEnum(T);
|
||||
if (IsSObject(T)) return FromObject(T);
|
||||
if (IsSTuple(T)) return FromTuple(T);
|
||||
if (IsSArray(T)) return FromArray(T);
|
||||
if (IsSConst(T)) return FromConst(T);
|
||||
if (IsSRef(T)) return FromRef(T);
|
||||
if (IsSString(T)) return Type.String(T);
|
||||
if (IsSNumber(T)) return Type.Number(T);
|
||||
if (IsSInteger(T)) return Type.Integer(T);
|
||||
if (IsSBoolean(T)) return Type.Boolean(T);
|
||||
if (IsSNull(T)) return Type.Null(T);
|
||||
return Type.Unknown(T || {});
|
||||
}
|
||||
18
src/index.ts
Normal file
18
src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { OperationType, OperationContextSchema, OperationDefinitionSchema, OperationSpecSchema, AccessControlSchema, ErrorDefinitionSchema } from "./types.js";
|
||||
export type { IOperationDefinition, OperationHandler, SubscriptionHandler, Identity, OperationEnv, OperationContext, OperationSpec, AccessControl, ErrorDefinition } from "./types.js";
|
||||
export { OperationRegistry } from "./registry.js";
|
||||
export { formatValueErrors, assertIsSchema, validateOrThrow, collectErrors } from "./validation.js";
|
||||
export { buildEnv } from "./env.js";
|
||||
export type { PendingRequestMap, EnvOptions } from "./env.js";
|
||||
export { FromSchema } from "./from_schema.js";
|
||||
export { FromOpenAPI, FromOpenAPIFile, FromOpenAPIUrl } from "./from_openapi.js";
|
||||
export type { OpenAPISpec, OpenAPIOperation, OpenAPIParameter, HTTPServiceConfig, OpenAPIFS } from "./from_openapi.js";
|
||||
export { scanOperations } from "./scanner.js";
|
||||
export type { OperationManifest, ScannerFS } from "./scanner.js";
|
||||
export { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
||||
export type { CallErrorCode } from "./error.js";
|
||||
export { PendingRequestMap as PendingRequestMapClass, buildCallHandler } from "./call.js";
|
||||
export type { CallEventMap, CallEventMapValue, CallRequestedEvent, CallRespondedEvent, CallAbortedEvent, CallErrorEvent, CallHandler, CallHandlerConfig } from "./call.js";
|
||||
export { subscribe } from "./subscribe.js";
|
||||
export { createMCPClient, closeMCPClient, MCPClientLoader } from "./from_mcp.js";
|
||||
export type { MCPClientConfig, MCPClientWrapper } from "./from_mcp.js";
|
||||
78
src/registry.ts
Normal file
78
src/registry.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { IOperationDefinition, OperationContext, OperationSpec } from "./types.js";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
|
||||
|
||||
const logger = getLogger("operations:registry");
|
||||
|
||||
export class OperationRegistry {
|
||||
private operations = new Map<string, IOperationDefinition>();
|
||||
|
||||
private getOperationId(operation: IOperationDefinition): string {
|
||||
return `${operation.namespace}.${operation.name}`;
|
||||
}
|
||||
|
||||
register(operation: IOperationDefinition): void {
|
||||
const opId = `${operation.namespace}.${operation.name}`;
|
||||
assertIsSchema(operation.inputSchema, `${opId} inputSchema`);
|
||||
assertIsSchema(operation.outputSchema, `${opId} outputSchema`);
|
||||
const id = this.getOperationId(operation);
|
||||
this.operations.set(id, operation);
|
||||
logger.info(`Registered operation: ${id}`);
|
||||
}
|
||||
|
||||
registerAll(operations: IOperationDefinition[]): void {
|
||||
for (const op of operations) {
|
||||
this.register(op);
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): IOperationDefinition | undefined {
|
||||
return this.operations.get(id);
|
||||
}
|
||||
|
||||
getByName(namespace: string, name: string): IOperationDefinition | undefined {
|
||||
return this.operations.get(`${namespace}.${name}`);
|
||||
}
|
||||
|
||||
list(): IOperationDefinition[] {
|
||||
return Array.from(this.operations.values());
|
||||
}
|
||||
|
||||
private extractSpec(operation: IOperationDefinition): OperationSpec {
|
||||
const { handler: _handler, ...spec } = operation;
|
||||
return spec;
|
||||
}
|
||||
|
||||
getSpec(id: string): OperationSpec | undefined {
|
||||
const operation = this.operations.get(id);
|
||||
return operation ? this.extractSpec(operation) : undefined;
|
||||
}
|
||||
|
||||
getAllSpecs(): OperationSpec[] {
|
||||
return this.list().map(op => this.extractSpec(op));
|
||||
}
|
||||
|
||||
async execute<TInput = unknown, TOutput = unknown>(
|
||||
operationId: string,
|
||||
input: TInput,
|
||||
context: OperationContext,
|
||||
): Promise<TOutput> {
|
||||
const operation = this.operations.get(operationId);
|
||||
|
||||
if (!operation) {
|
||||
throw new Error(`Operation not found: ${operationId}`);
|
||||
}
|
||||
|
||||
validateOrThrow(operation.inputSchema, input, `Input validation failed for ${operationId}`);
|
||||
|
||||
const result = await operation.handler(input, context) as TOutput;
|
||||
|
||||
const errors = collectErrors(operation.outputSchema, result);
|
||||
if (errors.length > 0) {
|
||||
logger.warn(`Output validation failed for ${operationId}:\n${formatValueErrors(errors)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
97
src/scanner.ts
Normal file
97
src/scanner.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { IOperationDefinition } from "./types.js";
|
||||
import { OperationDefinitionSchema } from "./types.js";
|
||||
import { collectErrors, formatValueErrors } from "./validation.js";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
|
||||
const logger = getLogger("operations:scanner");
|
||||
|
||||
export interface ScannerFS {
|
||||
readdir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean }>;
|
||||
cwd(): string;
|
||||
}
|
||||
|
||||
export interface OperationManifest {
|
||||
operations: Record<string, IOperationDefinition>;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export async function scanOperations(
|
||||
dirPath: string,
|
||||
fs: ScannerFS,
|
||||
): Promise<IOperationDefinition[]> {
|
||||
const operations: IOperationDefinition[] = [];
|
||||
|
||||
try {
|
||||
await processDirectory(dirPath, operations, fs);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error scanning directory ${dirPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
async function processDirectory(
|
||||
dirPath: string,
|
||||
operations: IOperationDefinition[],
|
||||
fs: ScannerFS,
|
||||
): Promise<void> {
|
||||
try {
|
||||
for await (const entry of fs.readdir(dirPath)) {
|
||||
const fullPath = `${dirPath}/${entry.name}`;
|
||||
|
||||
if (entry.isDirectory) {
|
||||
await processDirectory(fullPath, operations, fs);
|
||||
} else if (entry.isFile && entry.name.endsWith(".ts")) {
|
||||
try {
|
||||
const absolutePath = fullPath.startsWith("/") ? fullPath : `${fs.cwd()}/${fullPath}`;
|
||||
const moduleUrl = pathToFileURL(absolutePath);
|
||||
const module = await import(moduleUrl);
|
||||
|
||||
if (module.default) {
|
||||
const operation = module.default as IOperationDefinition;
|
||||
|
||||
const errors = collectErrors(OperationDefinitionSchema, operation);
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.warn(`${fullPath}: Invalid operation definition - ${formatValueErrors(errors, "")}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
operations.push(operation);
|
||||
logger.info(
|
||||
`Loaded operation: ${operation.namespace}.${operation.name} from ${fullPath}`,
|
||||
);
|
||||
} else {
|
||||
logger.warn(`${fullPath} does not export a default operation`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error processing ${fullPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error reading directory ${dirPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to process directory ${dirPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function pathToFileURL(absolutePath: string): string {
|
||||
return `file://${absolutePath}`;
|
||||
}
|
||||
28
src/subscribe.ts
Normal file
28
src/subscribe.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { IOperationDefinition, OperationContext } from "./types.js";
|
||||
import { OperationRegistry } from "./registry.js";
|
||||
|
||||
export async function* subscribe(
|
||||
registry: OperationRegistry,
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
context: OperationContext,
|
||||
): AsyncGenerator<unknown, void, unknown> {
|
||||
const operation = registry.get(operationId);
|
||||
|
||||
if (!operation) {
|
||||
throw new Error(`Operation not found: ${operationId}`);
|
||||
}
|
||||
|
||||
const handler = operation.handler;
|
||||
const generator = handler(input, context) as AsyncGenerator<unknown, void, unknown>;
|
||||
|
||||
try {
|
||||
for await (const value of generator) {
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
if (generator.return) {
|
||||
await generator.return(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
144
src/types.ts
Normal file
144
src/types.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Type, type Static, type TSchema } from "@alkdev/typebox";
|
||||
|
||||
export enum OperationType {
|
||||
QUERY = "query",
|
||||
MUTATION = "mutation",
|
||||
SUBSCRIPTION = "subscription",
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
id: string
|
||||
scopes: string[]
|
||||
resources?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type OperationEnv = Record<string, Record<string, (input: unknown) => Promise<unknown>>>
|
||||
|
||||
export const OperationContextSchema = Type.Object({
|
||||
metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
||||
requestId: Type.Optional(Type.String()),
|
||||
parentRequestId: Type.Optional(Type.String()),
|
||||
identity: Type.Optional(Type.Object({
|
||||
id: Type.String(),
|
||||
scopes: Type.Array(Type.String()),
|
||||
resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String())))
|
||||
})),
|
||||
}, {
|
||||
description: "Context provided to all operation handlers"
|
||||
});
|
||||
|
||||
type OperationContextBase = Static<typeof OperationContextSchema>
|
||||
|
||||
export type OperationContext = OperationContextBase & {
|
||||
env?: OperationEnv
|
||||
stream?: () => AsyncIterable<unknown>
|
||||
pubsub?: unknown
|
||||
}
|
||||
|
||||
export const ErrorDefinitionSchema = Type.Object({
|
||||
code: Type.String({
|
||||
description: "Error Code e.g., INVALID_INPUT, NOT_FOUND, UNAUTHORIZED"
|
||||
}),
|
||||
description: Type.String(),
|
||||
schema: Type.Unknown(),
|
||||
httpStatus: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
export type ErrorDefinition = Static<typeof ErrorDefinitionSchema>;
|
||||
|
||||
export const AccessControlSchema = Type.Object({
|
||||
requiredScopes: Type.Array(
|
||||
Type.String(),
|
||||
{description: "Required scopes (all must be present)"}
|
||||
),
|
||||
requiredScopesAny: Type.Optional(
|
||||
Type.Array(Type.String({description: "Required scopes (at least one must match)"}))),
|
||||
resourceType: Type.Optional(Type.String({description: "Resource Type e.g., project, tool, data"})),
|
||||
resourceAction: Type.Optional(Type.String({description: "Required action on the resource e.g., read, write, execute"})),
|
||||
customAuth: Type.Optional(Type.String({description: "Name of custom auth function"})),
|
||||
});
|
||||
|
||||
export type AccessControl = Static<typeof AccessControlSchema>;
|
||||
|
||||
export type OperationHandler<
|
||||
TInput = unknown,
|
||||
TOutput = unknown,
|
||||
TContext extends OperationContext = OperationContext,
|
||||
> = (
|
||||
input: TInput,
|
||||
context: TContext,
|
||||
) => Promise<TOutput> | TOutput;
|
||||
|
||||
export type SubscriptionHandler<
|
||||
TInput = unknown,
|
||||
TOutput = unknown,
|
||||
TContext extends OperationContext = OperationContext,
|
||||
> = (
|
||||
input: TInput,
|
||||
context: TContext,
|
||||
) => AsyncGenerator<TOutput, void, unknown>;
|
||||
|
||||
export const OperationDefinitionSchema = Type.Object({
|
||||
name: Type.String({ description: "Unique operation name" }),
|
||||
namespace: Type.String({
|
||||
description: "Namespace for grouping (e.g., 'task', 'graph', 'user')",
|
||||
}),
|
||||
version: Type.String({ description: "Semantic version (e.g., '1.0.0')" }),
|
||||
type: Type.Enum(OperationType, {
|
||||
description: "Operation type: query, mutation, or subscription",
|
||||
}),
|
||||
title: Type.Optional(Type.String({ description: "Human-readable title" })),
|
||||
description: Type.String({ description: "Detailed description" }),
|
||||
tags: Type.Optional(Type.Array(Type.String())),
|
||||
inputSchema: Type.Unknown({ description: "json schema for input" }),
|
||||
outputSchema: Type.Unknown({ description: "json schema for output" }),
|
||||
errorSchemas: Type.Optional(Type.Array(ErrorDefinitionSchema)),
|
||||
accessControl: AccessControlSchema,
|
||||
handler: Type.Unknown({ description: "Operation handler function" }),
|
||||
_meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
||||
});
|
||||
|
||||
export interface OperationSpec<
|
||||
TInput = unknown,
|
||||
TOutput = unknown,
|
||||
> {
|
||||
name: string;
|
||||
namespace: string;
|
||||
version: string;
|
||||
type: OperationType;
|
||||
title?: string;
|
||||
description: string;
|
||||
tags?: string[];
|
||||
inputSchema: TSchema;
|
||||
outputSchema: TSchema;
|
||||
errorSchemas?: ErrorDefinition[];
|
||||
accessControl: AccessControl;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const OperationSpecSchema = Type.Object({
|
||||
name: Type.String({ description: "Unique operation name" }),
|
||||
namespace: Type.String({
|
||||
description: "Namespace for grouping (e.g., 'task', 'graph', 'user')",
|
||||
}),
|
||||
version: Type.String({ description: "Semantic version (e.g., '1.0.0')" }),
|
||||
type: Type.Enum(OperationType, {
|
||||
description: "Operation type: query, mutation, or subscription",
|
||||
}),
|
||||
title: Type.Optional(Type.String({ description: "Human-readable title" })),
|
||||
description: Type.String({ description: "Detailed description" }),
|
||||
tags: Type.Optional(Type.Array(Type.String())),
|
||||
inputSchema: Type.Unknown({ description: "json schema for input" }),
|
||||
outputSchema: Type.Unknown({ description: "json schema for output" }),
|
||||
errorSchemas: Type.Optional(Type.Array(ErrorDefinitionSchema)),
|
||||
accessControl: AccessControlSchema,
|
||||
_meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
||||
});
|
||||
|
||||
export interface IOperationDefinition<
|
||||
TInput = unknown,
|
||||
TOutput = unknown,
|
||||
TContext extends OperationContext = OperationContext,
|
||||
> extends OperationSpec<TInput, TOutput> {
|
||||
handler: OperationHandler<TInput, TOutput, TContext> | SubscriptionHandler<TInput, TOutput, TContext>;
|
||||
}
|
||||
44
src/validation.ts
Normal file
44
src/validation.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { KindGuard, type TSchema } from "@alkdev/typebox";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
|
||||
export function formatValueErrors(
|
||||
errors: Iterable<{ path: string; message: string }>,
|
||||
indent: string = " - ",
|
||||
): string {
|
||||
return [...errors]
|
||||
.map((err) => `${indent}${err.path}: ${err.message}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function assertIsSchema(schema: unknown, context?: string): void {
|
||||
const contextMsg = context ? ` for ${context}` : "";
|
||||
if (!KindGuard.IsSchema(schema)) {
|
||||
throw new Error(`Not a valid TypeBox schema${contextMsg}. Use FromSchema() to convert JSON Schema to TypeBox.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateOrThrow(
|
||||
schema: TSchema,
|
||||
value: unknown,
|
||||
context?: string,
|
||||
): void {
|
||||
if (!Value.Check(schema, value)) {
|
||||
const errors = Value.Errors(schema, value);
|
||||
const formatted = formatValueErrors(errors);
|
||||
const contextMsg = context ? ` for ${context}` : "";
|
||||
throw new Error(`Validation failed${contextMsg}:\n${formatted}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function collectErrors(
|
||||
schema: TSchema,
|
||||
value: unknown,
|
||||
): Array<{ path: string; message: string }> {
|
||||
if (Value.Check(schema, value)) {
|
||||
return [];
|
||||
}
|
||||
return [...Value.Errors(schema, value)].map((err) => ({
|
||||
path: err.path,
|
||||
message: err.message,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user