feat(rpc): add defineService and the immutable procedure builder
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import type { ObjectSchema } from "@wrnexus/validation";
|
||||
import type { AnyProcedures, InferInput, ProcedureDef, ServiceContract } from "./types.ts";
|
||||
|
||||
/** A service name lands in a URL path, so keep it unescaped-safe. */
|
||||
const SAFE_SERVICE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
/** A procedure name is also a property the caller writes as client.doThing(). */
|
||||
const SAFE_PROCEDURE = /^[a-z][a-zA-Z0-9]*$/;
|
||||
|
||||
/**
|
||||
* Fluent, IMMUTABLE builder: every method returns a new builder, so a shared
|
||||
* base can be branched without one branch mutating another.
|
||||
*/
|
||||
export class ProcedureBuilder<Input, Output> {
|
||||
private constructor(private readonly def: ProcedureDef<Input, Output>) {}
|
||||
|
||||
static create(): ProcedureBuilder<void, void> {
|
||||
return new ProcedureBuilder<void, void>({});
|
||||
}
|
||||
|
||||
input<S extends ObjectSchema<object>>(schema: S): ProcedureBuilder<InferInput<S>, Output> {
|
||||
return new ProcedureBuilder<InferInput<S>, Output>({
|
||||
...this.def,
|
||||
input: schema,
|
||||
} as ProcedureDef<InferInput<S>, Output>);
|
||||
}
|
||||
|
||||
output<T>(): ProcedureBuilder<Input, T> {
|
||||
return new ProcedureBuilder<Input, T>({ ...this.def } as ProcedureDef<Input, T>);
|
||||
}
|
||||
|
||||
permission(id: string): ProcedureBuilder<Input, Output> {
|
||||
return new ProcedureBuilder<Input, Output>({ ...this.def, permission: id });
|
||||
}
|
||||
|
||||
/** Mark safe to retry. Anything not marked is never retried. */
|
||||
idempotent(): ProcedureBuilder<Input, Output> {
|
||||
return new ProcedureBuilder<Input, Output>({ ...this.def, idempotent: true });
|
||||
}
|
||||
|
||||
build(): ProcedureDef<Input, Output> {
|
||||
return Object.freeze({ ...this.def });
|
||||
}
|
||||
}
|
||||
|
||||
export const procedure = ProcedureBuilder.create();
|
||||
|
||||
export function defineService<Procedures extends AnyProcedures>(def: {
|
||||
name: string;
|
||||
procedures: Procedures;
|
||||
}): ServiceContract<Procedures> {
|
||||
if (!SAFE_SERVICE.test(def.name)) {
|
||||
throw new Error(
|
||||
`WRN-RPC-CONTRACT: service name ${JSON.stringify(def.name)} must be lowercase ` +
|
||||
`alphanumeric with single hyphens, e.g. "billing" or "billing-v2".`,
|
||||
);
|
||||
}
|
||||
for (const name of Object.keys(def.procedures)) {
|
||||
if (!SAFE_PROCEDURE.test(name)) {
|
||||
throw new Error(
|
||||
`WRN-RPC-CONTRACT: procedure name ${JSON.stringify(name)} on service ` +
|
||||
`'${def.name}' must be a lowercase-initial identifier, e.g. "createInvoice".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ name: def.name, procedures: Object.freeze({ ...def.procedures }) });
|
||||
}
|
||||
@@ -20,3 +20,5 @@ export type {
|
||||
|
||||
export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } from "./errors.ts";
|
||||
export type { RpcErrorCode, ToResultOptions } from "./errors.ts";
|
||||
|
||||
export { defineService, procedure, ProcedureBuilder } from "./contract.ts";
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { v } from "@wrnexus/validation";
|
||||
import { defineService, procedure } from "../src/contract.ts";
|
||||
|
||||
describe("defineService", () => {
|
||||
test("captures a procedure's input schema, permission and idempotency", () => {
|
||||
const billing = defineService({
|
||||
name: "billing",
|
||||
procedures: {
|
||||
createInvoice: procedure
|
||||
.input(v.object({ userId: v.string(), amountCents: v.number() }))
|
||||
.output<{ invoiceId: string }>()
|
||||
.permission("invoice:create")
|
||||
.build(),
|
||||
getInvoice: procedure
|
||||
.input(v.object({ invoiceId: v.string() }))
|
||||
.output<{ amountCents: number }>()
|
||||
.idempotent()
|
||||
.build(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(billing.name).toBe("billing");
|
||||
expect(Object.keys(billing.procedures).sort()).toEqual(["createInvoice", "getInvoice"]);
|
||||
expect(billing.procedures.createInvoice.permission).toBe("invoice:create");
|
||||
expect(billing.procedures.createInvoice.idempotent).toBeUndefined();
|
||||
expect(billing.procedures.getInvoice.idempotent).toBe(true);
|
||||
expect(billing.procedures.getInvoice.permission).toBeUndefined();
|
||||
});
|
||||
|
||||
test("the contract is frozen so it cannot drift after definition", () => {
|
||||
const contract = defineService({ name: "demo", procedures: { ping: procedure.build() } });
|
||||
expect(Object.isFrozen(contract)).toBe(true);
|
||||
expect(Object.isFrozen(contract.procedures)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a service name that is not a safe path segment", () => {
|
||||
// The name lands in a URL path, so it must not need escaping.
|
||||
for (const name of ["", "has space", "has/slash", "has.dot", "UPPER"]) {
|
||||
expect(() => defineService({ name, procedures: {} })).toThrow(/service name/i);
|
||||
}
|
||||
expect(() => defineService({ name: "billing-v2", procedures: {} })).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects a procedure name that is not a safe path segment", () => {
|
||||
expect(() =>
|
||||
defineService({ name: "demo", procedures: { "bad name": procedure.build() } }),
|
||||
).toThrow(/procedure name/i);
|
||||
});
|
||||
|
||||
test("the builder is immutable — reusing a base does not cross-contaminate", () => {
|
||||
const base = procedure.permission("a:read");
|
||||
const one = base.idempotent().build();
|
||||
const two = base.build();
|
||||
expect(one.idempotent).toBe(true);
|
||||
expect(two.idempotent).toBeUndefined();
|
||||
expect(two.permission).toBe("a:read");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user