diff --git a/packages/rpc/src/contract.ts b/packages/rpc/src/contract.ts new file mode 100644 index 00000000..ad7c27f3 --- /dev/null +++ b/packages/rpc/src/contract.ts @@ -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 { + private constructor(private readonly def: ProcedureDef) {} + + static create(): ProcedureBuilder { + return new ProcedureBuilder({}); + } + + input>(schema: S): ProcedureBuilder, Output> { + return new ProcedureBuilder, Output>({ + ...this.def, + input: schema, + } as ProcedureDef, Output>); + } + + output(): ProcedureBuilder { + return new ProcedureBuilder({ ...this.def } as ProcedureDef); + } + + permission(id: string): ProcedureBuilder { + return new ProcedureBuilder({ ...this.def, permission: id }); + } + + /** Mark safe to retry. Anything not marked is never retried. */ + idempotent(): ProcedureBuilder { + return new ProcedureBuilder({ ...this.def, idempotent: true }); + } + + build(): ProcedureDef { + return Object.freeze({ ...this.def }); + } +} + +export const procedure = ProcedureBuilder.create(); + +export function defineService(def: { + name: string; + procedures: Procedures; +}): ServiceContract { + 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 }) }); +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 054b9d19..4ec5e5ae 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -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"; diff --git a/packages/rpc/test/contract.test.ts b/packages/rpc/test/contract.test.ts new file mode 100644 index 00000000..d3acc9fe --- /dev/null +++ b/packages/rpc/test/contract.test.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"); + }); +});