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
19 changes: 19 additions & 0 deletions .changeset/runtime-domain-handler-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/runtime": minor
---

feat(runtime): thin domain-handler registry seam in the HTTP dispatcher — ADR-0076 D11 step ③, PR-1 (#2462)

`dispatch()` routed every domain through one hand-written
`if (cleanPath.startsWith('/xxx'))` chain — the "god implementation on a clean
port" shape ADR-0076 D11 calls out. This lands the decomposition seam: a
first-match `DomainHandlerRegistry` consulted before the legacy chain, plus a
public `HttpDispatcher.registerDomainHandler()` so follow-up PRs can hand each
domain's normalized handler to its owning service package.

Migration discipline is "registry first, code moves later, ownership last":
this PR only wraps four existing branches (`/health`, `/ready`, `/analytics`,
`/i18n` — three shapes: no-service probe, service bridge, optional-service
501) into registry entries with faithful legacy matching semantics. Zero
behavior change, locked by the 41-assertion http-conformance cross-adapter
suite and 11 new seam tests.
154 changes: 154 additions & 0 deletions packages/runtime/src/domain-handler-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0076 D11 step ③ (#2462) — the thin domain-handler registry seam.
*
* Two layers under test:
* 1. `DomainHandlerRegistry` matching semantics (first-match, exact vs
* prefix, method restriction) — deliberately faithful to the legacy
* if-chain, rough edges included.
* 2. `HttpDispatcher` integration: the four seeded builtin domains
* (/health /ready /analytics /i18n) behave exactly as their legacy
* if-chain branches did, and `registerDomainHandler` is the public
* seam follow-up domain PRs will use.
*/

import { describe, it, expect, vi } from 'vitest';
import { HttpDispatcher } from './http-dispatcher.js';
import { DomainHandlerRegistry } from './domain-handler-registry.js';
import type { DomainHandler } from './domain-handler-registry.js';

const okHandler = (tag: string): DomainHandler =>
async () => ({ handled: true, response: { status: 200, body: { tag } } });

/** Minimal kernel: objectql + optional extra services. */
function makeKernel(services: Record<string, any> = {}, state = 'running') {
const objectql = {
find: vi.fn().mockResolvedValue([]),
getObjects: vi.fn().mockReturnValue({}),
registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) },
};
const all: Record<string, any> = { objectql, ...services };
const kernel: any = {
getState: () => state,
getService: (name: string) => all[name] ?? null,
getServiceAsync: async (name: string) => all[name] ?? null,
context: { getService: (name: string) => all[name] ?? null },
};
return kernel;
}

function makeDispatcher(services: Record<string, any> = {}, state = 'running') {
return new HttpDispatcher(makeKernel(services, state), undefined, {
enforceProjectMembership: false,
});
}

// ---------------------------------------------------------------------------
// DomainHandlerRegistry matching semantics
// ---------------------------------------------------------------------------

describe('DomainHandlerRegistry', () => {
it('resolves in registration order, first match wins', () => {
const registry = new DomainHandlerRegistry();
const first = okHandler('first');
const second = okHandler('second');
registry.register({ prefix: '/a', handler: first });
registry.register({ prefix: '/a', handler: second });
expect(registry.resolve('/a/x', 'GET')?.handler).toBe(first);
});

it("match: 'exact' does not claim sub-paths; default prefix match does (legacy startsWith, rough edges included)", () => {
const registry = new DomainHandlerRegistry();
registry.register({ prefix: '/health', match: 'exact', handler: okHandler('h') });
registry.register({ prefix: '/i18n', handler: okHandler('i') });
expect(registry.resolve('/health', 'GET')).toBeDefined();
expect(registry.resolve('/health/deep', 'GET')).toBeUndefined();
expect(registry.resolve('/i18n/locales', 'GET')).toBeDefined();
// Faithful legacy semantics: bare startsWith also matches '/i18nxx'.
expect(registry.resolve('/i18nxx', 'GET')).toBeDefined();
});

it('restricts by method when `methods` is set (case-insensitive on input)', () => {
const registry = new DomainHandlerRegistry();
registry.register({ prefix: '/health', match: 'exact', methods: ['GET'], handler: okHandler('h') });
expect(registry.resolve('/health', 'get')).toBeDefined();
expect(registry.resolve('/health', 'POST')).toBeUndefined();
});

it('rejects a prefix that does not start with a slash', () => {
const registry = new DomainHandlerRegistry();
expect(() => registry.register({ prefix: 'health', handler: okHandler('h') })).toThrow(/prefix/);
});
});

// ---------------------------------------------------------------------------
// Dispatcher integration — seeded builtin domains keep legacy behavior
// ---------------------------------------------------------------------------

describe('HttpDispatcher domain registry (D11 step ③)', () => {
it('GET /health serves the liveness payload', async () => {
const result = await makeDispatcher().dispatch('GET', '/health', undefined, {}, {} as any);
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.status).toBe('ok');
});

it('GET /ready reflects kernel state: running → 200, booting → 503', async () => {
const ready = await makeDispatcher({}, 'running').dispatch('GET', '/ready', undefined, {}, {} as any);
expect(ready.response?.status).toBe(200);
expect(ready.response?.body?.data?.state).toBe('running');

const booting = await makeDispatcher({}, 'initializing').dispatch('GET', '/ready', undefined, {}, {} as any);
expect(booting.response?.status).toBe(503);
expect(booting.response?.body?.error?.details?.state).toBe('initializing');
});

it('non-GET /health is NOT claimed by the health domain (falls through the legacy chain)', async () => {
const result = await makeDispatcher().dispatch('POST', '/health', undefined, {}, {} as any);
// Same as before the registry: no branch claims POST /health.
expect(result.response?.status ?? 404).not.toBe(200);
});

it('/i18n keeps its in-handler 501 when the i18n service is absent', async () => {
const result = await makeDispatcher().dispatch('GET', '/i18n/locales', undefined, {}, {} as any);
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(501);
});

it('/i18n/locales serves from the i18n service when present', async () => {
const i18n = { getLocales: vi.fn().mockReturnValue(['en', 'zh-CN']), getTranslations: vi.fn().mockReturnValue({}) };
const result = await makeDispatcher({ i18n }).dispatch('GET', '/i18n/locales', undefined, {}, {} as any);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN']);
});

it('/analytics bridges to the analytics service exactly as the legacy branch did', async () => {
const analytics = {
query: vi.fn().mockResolvedValue({ rows: [{ n: 1 }] }),
analyticsQuery: vi.fn().mockResolvedValue({ rows: [{ n: 1 }] }),
};
const result = await makeDispatcher({ analytics }).dispatch(
'POST', '/analytics/query', { metric: 'count' }, {}, {} as any,
);
expect(result.handled).toBe(true);
// The bridge consulted the service (whichever entry point it uses).
expect(
analytics.query.mock.calls.length + analytics.analyticsQuery.mock.calls.length,
).toBeGreaterThan(0);
});

it('registerDomainHandler is the public seam: a service-owned domain resolves before the legacy chain', async () => {
const dispatcher = makeDispatcher();
const handler = vi.fn(async (req: any) => ({
handled: true as const,
response: { status: 200, body: { echo: req.path } },
}));
dispatcher.registerDomainHandler({ prefix: '/custom-domain', handler });

const result = await dispatcher.dispatch('GET', '/custom-domain/thing', undefined, { q: '1' }, {} as any);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0]).toMatchObject({ path: '/custom-domain/thing', method: 'GET' });
expect(result.response?.body?.echo).toBe('/custom-domain/thing');
});
});
97 changes: 97 additions & 0 deletions packages/runtime/src/domain-handler-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Domain handler registry — the thin routing seam of ADR-0076 D11 step ③
* (#2462).
*
* The HTTP dispatcher historically routed every domain through one hand-written
* `if (cleanPath.startsWith('/xxx'))` chain inside `dispatch()`, and every
* domain's handler lived as a method on the dispatcher class — the "god
* implementation on a clean port" shape ADR-0076 D11 calls out. This registry
* is the decomposition seam: `dispatch()` consults it FIRST, and domains are
* migrated out of the if-chain one PR at a time.
*
* Migration discipline (registry first, code moves later, ownership last):
* 1. This PR: the dispatcher wraps its existing `handleXxx` methods into
* registry entries at construction time — same matching semantics, same
* handler bodies, zero behavior change (locked by the http-conformance
* cross-adapter suite).
* 2. Follow-up PRs: a domain's handler body moves into its owning service
* package, which registers the entry itself (via
* {@link HttpDispatcher.registerDomainHandler}). Service-absence
* semantics (today's in-handler 501 vs route-not-mounted 404) are decided
* per-domain at THAT point, together with the D12 honest-capabilities
* discovery contract.
*
* Matching semantics are deliberately faithful to the legacy if-chain,
* INCLUDING its rough edges (`match: 'prefix'` on `/i18n` also matches
* `/i18nxx`, exactly as `startsWith` did) — fixing those edges is explicitly
* not this seam's job; behavior preservation is.
*/

import type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js';

/**
* The normalized request slice a domain handler receives. `path` is the
* dispatcher's `cleanPath` — API prefix and `/environments/:id` scope already
* stripped, NO domain-prefix stripping (each handler keeps its historical
* substring convention until its domain PR normalizes it).
*/
export interface DomainRequest {
path: string;
method: string;
body: any;
query: any;
}

/** Normalized per-domain handler — the dispatcher-independent handler shape. */
export type DomainHandler = (
req: DomainRequest,
context: HttpProtocolContext,
) => Promise<HttpDispatcherResult>;

export interface DomainRoute {
/** Path prefix the domain claims, e.g. `'/i18n'`. */
prefix: string;
/**
* `'prefix'` — legacy `startsWith(prefix)` semantics (default).
* `'exact'` — the path must equal the prefix exactly.
*/
match?: 'prefix' | 'exact';
/** Restrict to these UPPERCASE HTTP methods. Omit = all methods. */
methods?: string[];
handler: DomainHandler;
}

/**
* First-match-wins routing table, in registration order. Kept deliberately
* minimal — no wildcards, no params, no middleware: those belong to the real
* HTTP adapters. This seam only decides "which domain owns this path".
*/
export class DomainHandlerRegistry {
private readonly routes: DomainRoute[] = [];

register(route: DomainRoute): void {
if (!route.prefix.startsWith('/')) {
throw new Error(`DomainHandlerRegistry: prefix must start with '/', got '${route.prefix}'`);
}
this.routes.push(route);
}

/** Resolve the first route claiming `path` (+`method`), else undefined. */
resolve(path: string, method: string): DomainRoute | undefined {
const m = method.toUpperCase();
for (const route of this.routes) {
if (route.methods && !route.methods.includes(m)) continue;
if (route.match === 'exact' ? path === route.prefix : path.startsWith(route.prefix)) {
return route;
}
}
return undefined;
}

/** Registered routes, in match order (introspection / tests). */
list(): readonly DomainRoute[] {
return this.routes;
}
}
Loading