Initial Commit
This commit is contained in:
72
packages/api-stack/api/api-schemas/README.md
Normal file
72
packages/api-stack/api/api-schemas/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# @suaveplan/api-schemas - Shared Zod schemas describing Suaveplan API request/response shapes — single source of truth for server plugins, React data hooks, and generated OpenAPI documents
|
||||
|
||||
## Summary
|
||||
|
||||
Shared Zod schemas describing Suaveplan API request/response shapes. The single source of truth for server plugins, React data hooks, and generated OpenAPI documents.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [health](./src/admin/health.md) — Defines Zod schemas for the admin health check endpoint (`GET /admin/health`). The health endpoint exposes a structured…
|
||||
- [ping](./src/admin/ping.md) — Provides Zod schemas for the admin ping endpoint (`GET /admin/ping`). The ping endpoint is the simplest possible health…
|
||||
- [status](./src/admin/status.md) — Defines the Zod schema for the admin status endpoint (`GET /admin/status`), which exposes static deployment metadata fo…
|
||||
- [context](./src/auth/context.md) — Defines the `AuthContext` Zod schema representing the authenticated caller's identity as it flows through a request lif…
|
||||
- [login](./src/auth/login.md) — Defines Zod schemas for the login request and response shapes used by Suaveplan's authentication endpoints. These schem…
|
||||
- [logout](./src/auth/logout.md) — Provides Zod schemas for the logout request and response shapes used by Suaveplan's `POST /auth/logout` endpoint. Logou…
|
||||
- [refresh](./src/auth/refresh.md) — Defines Zod schemas for the token-refresh request and response shapes used by Suaveplan's `POST /auth/refresh` endpoint…
|
||||
- [register](./src/auth/register.md) — Provides Zod schemas for the user registration request and response shapes used by Suaveplan's `POST /auth/register` en…
|
||||
- [error-envelope](./src/common/error-envelope.md) — Defines Zod schemas for the standard error response envelope used across all Suaveplan API endpoints. By sharing these…
|
||||
- [filter](./src/common/filter.md) — Defines Zod schemas for filter predicate inputs used in Suaveplan list and search endpoints. These schemas provide a st…
|
||||
- [id-param](./src/common/id-param.md) — Provides Zod schemas for URL path parameter objects carrying resource identifiers. Rather than validating raw strings a…
|
||||
- [pagination](./src/common/pagination.md) — Defines Zod schemas for pagination input and output shapes used across all Suaveplan list endpoints. These schemas serv…
|
||||
- [sort](./src/common/sort.md) — Provides Zod schemas for sort criteria used in Suaveplan list and search endpoints. By centralising the `SortInput` and…
|
||||
- [timestamps](./src/common/timestamps.md) — Defines Zod schemas for the audit timestamp fields (`createdAt`, `updatedAt`, and optionally `deletedAt`) that appear o…
|
||||
- [domain-event](./src/events/domain-event.md) — Defines the `DomainEvent` Zod schema — the canonical wire format for immutable domain events flowing through the Suavep…
|
||||
- [envelope](./src/events/envelope.md) — Defines the `EventEnvelope` Zod schema — the transport wrapper that carries a domain event across message brokers, even…
|
||||
- [metadata](./src/events/metadata.md) — Defines the `EventMetadata` Zod schema for the distributed tracing and actor context attached to every domain event in…
|
||||
- [helpers](./src/helpers/to-suaveplan-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [to-suaveplan-error](./src/helpers/to-suaveplan-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [to-suaveplan-error](./src/helpers/to-suaveplan-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [to-suaveplan-error](./src/helpers/to-suaveplan-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [to-suaveplan-error](./src/helpers/to-suaveplan-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [to-suaveplan-error](./src/helpers/to-suaveplan-error.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [common/pagination](src/common/pagination.md) | Cursor-based and offset-based pagination input/output |
|
||||
| [common/sort](src/common/sort.md) | Sort criteria (field + direction, multi-column) |
|
||||
| [common/filter](src/common/filter.md) | Filter predicates with 13 comparison operators |
|
||||
| [common/id-param](src/common/id-param.md) | URL path parameters for single and nested resources |
|
||||
| [common/timestamps](src/common/timestamps.md) | Audit timestamps (createdAt/updatedAt, soft-delete) |
|
||||
| [common/error-envelope](src/common/error-envelope.md) | Structured error responses with field-level details |
|
||||
| [auth/login](src/auth/login.md) | Login request/response (credentials → token + user) |
|
||||
| [auth/register](src/auth/register.md) | Registration request/response |
|
||||
| [auth/refresh](src/auth/refresh.md) | Token refresh with rotation |
|
||||
| [auth/logout](src/auth/logout.md) | Session revocation |
|
||||
| [auth/context](src/auth/context.md) | Decoded auth context (userId, roles, tenantId) |
|
||||
| [admin/ping](src/admin/ping.md) | Sentinel liveness check |
|
||||
| [admin/health](src/admin/health.md) | Aggregate + per-subsystem health status |
|
||||
| [admin/status](src/admin/status.md) | Deployment metadata (version, uptime, environment) |
|
||||
| [events/metadata](src/events/metadata.md) | Event correlation and actor context |
|
||||
| [events/domain-event](src/events/domain-event.md) | Immutable domain event record |
|
||||
| [events/envelope](src/events/envelope.md) | Transport wrapper with sequence number |
|
||||
| [helpers/to-suaveplan-error](src/helpers/to-suaveplan-error.md) | Convert ZodError → ErrorEnvelope |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-schemas
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { LoginInput, LoginOutput } from "@suaveplan/api-schemas/auth";
|
||||
import { PaginationInput, ErrorEnvelope } from "@suaveplan/api-schemas/common";
|
||||
import { AdminHealthOutput } from "@suaveplan/api-schemas/admin";
|
||||
import { EventEnvelope } from "@suaveplan/api-schemas/events";
|
||||
import { toSuaveplanError } from "@suaveplan/api-schemas/helpers";
|
||||
import z from "@suaveplan/api-schemas"; // re-exported for convenience
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Runtime: `zod`, `tslib`, `@opentelemetry/api`. No `@suaveplan/*` runtime dependencies (leaf package).
|
||||
112
packages/api-stack/api/api-schemas/package.json
Normal file
112
packages/api-stack/api/api-schemas/package.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "@suaveplan/api-schemas",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Shared Zod schemas describing Suaveplan API request/response shapes — single source of truth for server plugins, React data hooks, and generated OpenAPI documents",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./common": {
|
||||
"import": {
|
||||
"types": "./dist/common/index.d.ts",
|
||||
"default": "./dist/common/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/common/index.d.ts",
|
||||
"default": "./dist/common/index.js"
|
||||
}
|
||||
},
|
||||
"./auth": {
|
||||
"import": {
|
||||
"types": "./dist/auth/index.d.ts",
|
||||
"default": "./dist/auth/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/auth/index.d.ts",
|
||||
"default": "./dist/auth/index.js"
|
||||
}
|
||||
},
|
||||
"./admin": {
|
||||
"import": {
|
||||
"types": "./dist/admin/index.d.ts",
|
||||
"default": "./dist/admin/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/admin/index.d.ts",
|
||||
"default": "./dist/admin/index.js"
|
||||
}
|
||||
},
|
||||
"./events": {
|
||||
"import": {
|
||||
"types": "./dist/events/index.d.ts",
|
||||
"default": "./dist/events/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/events/index.d.ts",
|
||||
"default": "./dist/events/index.js"
|
||||
}
|
||||
},
|
||||
"./helpers": {
|
||||
"import": {
|
||||
"types": "./dist/helpers/index.d.ts",
|
||||
"default": "./dist/helpers/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/helpers/index.d.ts",
|
||||
"default": "./dist/helpers/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
85
packages/api-stack/api/api-schemas/src/admin/health.md
Normal file
85
packages/api-stack/api/api-schemas/src/admin/health.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# health
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines Zod schemas for the admin health check endpoint (`GET /admin/health`). The health endpoint exposes a structured view of the service's operational status — both an aggregate roll-up and per-subsystem detail — so that load balancers, container orchestrators, and monitoring dashboards can make informed routing and alerting decisions without parsing unstructured text responses.
|
||||
|
||||
## Features
|
||||
|
||||
- `HealthStatus` enum schema — `"ok" | "degraded" | "down"` covering the full range of operational states
|
||||
- `HealthCheck` object schema — per-subsystem check with status, optional latency measurement, and optional message
|
||||
- `AdminHealthOutput` schema — aggregate `status` plus a `checks` record keyed by subsystem name (non-empty keys)
|
||||
- `latencyMs` validated as a non-negative number — negative latency is not physically meaningful
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AdminHealthOutput } from "@suaveplan/api-schemas/admin";
|
||||
|
||||
const health = AdminHealthOutput.parse({
|
||||
status: "ok",
|
||||
checks: {
|
||||
database: { status: "ok", latencyMs: 3 },
|
||||
cache: { status: "ok", latencyMs: 1 },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AdminHealthOutput, HealthStatus } from "@suaveplan/api-schemas/admin";
|
||||
|
||||
async function collectHealth(): Promise<AdminHealthOutput> {
|
||||
const dbLatency = await measureDbPing();
|
||||
const cacheLatency = await measureCachePing();
|
||||
|
||||
const checks = {
|
||||
database: { status: dbLatency < 100 ? "ok" : "degraded" as HealthStatus, latencyMs: dbLatency },
|
||||
cache: { status: cacheLatency < 10 ? "ok" : "degraded" as HealthStatus, latencyMs: cacheLatency },
|
||||
};
|
||||
|
||||
const anyDown = Object.values(checks).some((c) => c.status === "down");
|
||||
const anyDegraded = Object.values(checks).some((c) => c.status === "degraded");
|
||||
const status: HealthStatus = anyDown ? "down" : anyDegraded ? "degraded" : "ok";
|
||||
|
||||
return AdminHealthOutput.parse({ status, checks });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `HealthStatus`
|
||||
|
||||
Zod enum: `"ok" | "degraded" | "down"`.
|
||||
|
||||
**Type export:** `type HealthStatus = z.infer<typeof HealthStatus>`
|
||||
|
||||
### `HealthCheck`
|
||||
|
||||
Zod object schema — single subsystem check result.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|----------------------|--------------------------------------------------|
|
||||
| `status` | `HealthStatus` | Status of this individual check |
|
||||
| `latencyMs` | `number` (≥ 0) | Round-trip latency; omit when not measured |
|
||||
| `message` | `string` | Optional human-readable context |
|
||||
|
||||
**Type export:** `type HealthCheck = z.infer<typeof HealthCheck>`
|
||||
|
||||
### `AdminHealthOutput`
|
||||
|
||||
Zod object schema — health endpoint response.
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------|-------------------------------------|-----------------------------------------------|
|
||||
| `status` | `HealthStatus` | Aggregate service status |
|
||||
| `checks` | `Record<string, HealthCheck>` | Keyed map of subsystem check results |
|
||||
|
||||
**Type export:** `type AdminHealthOutput = z.infer<typeof AdminHealthOutput>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The aggregate `status` should be the worst of all individual check statuses — if any check is `"down"` the aggregate should be `"down"`; if any is `"degraded"` and none are `"down"` the aggregate should be `"degraded"`. This roll-up logic belongs in the route handler, not the schema. The `checks` record uses `z.record(z.string().min(1), ...)` to enforce non-empty subsystem names, preventing accidental empty-string keys that would make the map structure ambiguous.
|
||||
111
packages/api-stack/api/api-schemas/src/admin/health.test.ts
Normal file
111
packages/api-stack/api/api-schemas/src/admin/health.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AdminHealthOutput, HealthCheck, HealthStatus } from "./health.js";
|
||||
|
||||
describe("HealthStatus", () => {
|
||||
it("accepts ok", { timeout: 5000 }, () => {
|
||||
expect(HealthStatus.parse("ok")).toBe("ok");
|
||||
});
|
||||
|
||||
it("accepts degraded", { timeout: 5000 }, () => {
|
||||
expect(HealthStatus.parse("degraded")).toBe("degraded");
|
||||
});
|
||||
|
||||
it("accepts down", { timeout: 5000 }, () => {
|
||||
expect(HealthStatus.parse("down")).toBe("down");
|
||||
});
|
||||
|
||||
it("rejects unknown status", { timeout: 5000 }, () => {
|
||||
expect(() => HealthStatus.parse("healthy")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HealthCheck", () => {
|
||||
it("parses minimal health check (status only)", { timeout: 5000 }, () => {
|
||||
const result = HealthCheck.parse({ status: "ok" });
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.latencyMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses health check with latencyMs", { timeout: 5000 }, () => {
|
||||
const result = HealthCheck.parse({
|
||||
status: "degraded",
|
||||
latencyMs: 250,
|
||||
});
|
||||
expect(result.latencyMs).toBe(250);
|
||||
});
|
||||
|
||||
it("parses health check with message", { timeout: 5000 }, () => {
|
||||
const result = HealthCheck.parse({
|
||||
status: "down",
|
||||
message: "Connection refused",
|
||||
});
|
||||
expect(result.message).toBe("Connection refused");
|
||||
});
|
||||
|
||||
it("parses health check with all fields", { timeout: 5000 }, () => {
|
||||
const result = HealthCheck.parse({
|
||||
status: "ok",
|
||||
latencyMs: 5,
|
||||
message: "Healthy",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
status: "ok",
|
||||
latencyMs: 5,
|
||||
message: "Healthy",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects negative latencyMs", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
HealthCheck.parse({ status: "ok", latencyMs: -1 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts zero latencyMs", { timeout: 5000 }, () => {
|
||||
const result = HealthCheck.parse({ status: "ok", latencyMs: 0 });
|
||||
expect(result.latencyMs).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects invalid status", { timeout: 5000 }, () => {
|
||||
expect(() => HealthCheck.parse({ status: "unknown" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdminHealthOutput", () => {
|
||||
const valid = {
|
||||
status: "ok" as const,
|
||||
checks: {
|
||||
database: { status: "ok" as const, latencyMs: 3 },
|
||||
cache: { status: "ok" as const },
|
||||
},
|
||||
};
|
||||
|
||||
it("parses valid health output", { timeout: 5000 }, () => {
|
||||
const result = AdminHealthOutput.parse(valid);
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.checks["database"]?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("parses with empty checks record", { timeout: 5000 }, () => {
|
||||
const result = AdminHealthOutput.parse({ status: "ok", checks: {} });
|
||||
expect(result.checks).toEqual({});
|
||||
});
|
||||
|
||||
it("parses degraded status", { timeout: 5000 }, () => {
|
||||
const result = AdminHealthOutput.parse({
|
||||
status: "degraded",
|
||||
checks: { db: { status: "degraded", latencyMs: 500 } },
|
||||
});
|
||||
expect(result.status).toBe("degraded");
|
||||
});
|
||||
|
||||
it("rejects invalid overall status", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminHealthOutput.parse({ ...valid, status: "unknown" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing checks field", { timeout: 5000 }, () => {
|
||||
expect(() => AdminHealthOutput.parse({ status: "ok" })).toThrow();
|
||||
});
|
||||
});
|
||||
37
packages/api-stack/api/api-schemas/src/admin/health.ts
Normal file
37
packages/api-stack/api/api-schemas/src/admin/health.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Enumeration of possible health statuses for the overall service
|
||||
* or individual health check components.
|
||||
*/
|
||||
export const HealthStatus = z.enum(["ok", "degraded", "down"]);
|
||||
|
||||
export type HealthStatus = z.infer<typeof HealthStatus>;
|
||||
|
||||
/**
|
||||
* Schema for a single named health check result.
|
||||
* Each check reports its status and an optional latency measurement.
|
||||
*/
|
||||
export const HealthCheck = z.object({
|
||||
/** Current status of this individual health check. */
|
||||
status: HealthStatus,
|
||||
/** Round-trip latency in milliseconds for this check (optional). */
|
||||
latencyMs: z.number().min(0).optional(),
|
||||
/** Optional human-readable message providing additional context. */
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type HealthCheck = z.infer<typeof HealthCheck>;
|
||||
|
||||
/**
|
||||
* Schema for the admin health check response body.
|
||||
* Reports aggregate service status and per-subsystem check results.
|
||||
*/
|
||||
export const AdminHealthOutput = z.object({
|
||||
/** Aggregate status of the service. "ok" means all checks pass. */
|
||||
status: HealthStatus,
|
||||
/** Map of named subsystem checks (e.g. "database", "cache", "queue"). */
|
||||
checks: z.record(z.string().min(1), HealthCheck),
|
||||
});
|
||||
|
||||
export type AdminHealthOutput = z.infer<typeof AdminHealthOutput>;
|
||||
3
packages/api-stack/api/api-schemas/src/admin/index.ts
Normal file
3
packages/api-stack/api/api-schemas/src/admin/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { AdminHealthOutput, HealthCheck, HealthStatus } from "./health.js";
|
||||
export { AdminPingInput, AdminPingOutput } from "./ping.js";
|
||||
export { AdminStatusOutput } from "./status.js";
|
||||
63
packages/api-stack/api/api-schemas/src/admin/ping.md
Normal file
63
packages/api-stack/api/api-schemas/src/admin/ping.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# ping
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas for the admin ping endpoint (`GET /admin/ping`). The ping endpoint is the simplest possible health indicator — it verifies that the server is alive, the request pipeline is functional, and the clock is ticking. These schemas are used by server route handlers, monitoring systems, and the OpenAPI generator to define the contract for this sentinel endpoint.
|
||||
|
||||
## Features
|
||||
|
||||
- `AdminPingInput` schema — empty object (no input required); useful as a typed placeholder for framework plugins that require a body schema
|
||||
- `AdminPingOutput` schema — `{pong: true, timestamp}` where `pong` is a typed literal and `timestamp` is an ISO 8601 string with timezone offset
|
||||
- `pong: z.literal(true)` ensures TypeScript narrows the field to `true`, not `boolean`
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AdminPingOutput } from "@suaveplan/api-schemas/admin";
|
||||
|
||||
const response = AdminPingOutput.parse({
|
||||
pong: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// → { pong: true, timestamp: "2024-06-01T12:00:00.000Z" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AdminPingInput, AdminPingOutput } from "@suaveplan/api-schemas/admin";
|
||||
|
||||
// Route handler using both input and output schemas
|
||||
async function handlePing(_rawBody: unknown): Promise<AdminPingOutput> {
|
||||
AdminPingInput.parse(_rawBody); // no-op for empty body, ensures shape check
|
||||
return AdminPingOutput.parse({
|
||||
pong: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AdminPingInput`
|
||||
|
||||
Zod object schema — empty input (no fields).
|
||||
|
||||
**Type export:** `type AdminPingInput = z.infer<typeof AdminPingInput>`
|
||||
|
||||
### `AdminPingOutput`
|
||||
|
||||
Zod object schema — ping response.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|----------------------------------|------------------------------------------|
|
||||
| `pong` | `true` (literal) | Confirms endpoint reachability |
|
||||
| `timestamp` | `string` (ISO 8601 with offset) | Server clock time at request processing |
|
||||
|
||||
**Type export:** `type AdminPingOutput = z.infer<typeof AdminPingOutput>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `pong` field uses `z.literal(true)` rather than `z.boolean()` to make the success state an unambiguous type-level fact — TypeScript will infer `pong: true` not `pong: boolean`, enabling exhaustiveness checks. The `timestamp` is included so monitoring tools can detect clock skew between the client and server without an additional round-trip to a time endpoint. The `AdminPingInput` empty-object schema exists to keep route plugin signatures uniform: every endpoint has both an input and an output schema.
|
||||
64
packages/api-stack/api/api-schemas/src/admin/ping.test.ts
Normal file
64
packages/api-stack/api/api-schemas/src/admin/ping.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AdminPingInput, AdminPingOutput } from "./ping.js";
|
||||
|
||||
describe("AdminPingInput", () => {
|
||||
it("parses empty object", { timeout: 5000 }, () => {
|
||||
const result = AdminPingInput.parse({});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("parses object with extra fields (stripped)", { timeout: 5000 }, () => {
|
||||
// Zod strips extra keys by default
|
||||
const result = AdminPingInput.parse({ extra: "ignored" });
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdminPingOutput", () => {
|
||||
const valid = {
|
||||
pong: true as const,
|
||||
timestamp: "2024-06-01T12:00:00.000Z",
|
||||
};
|
||||
|
||||
it("parses valid ping output", { timeout: 5000 }, () => {
|
||||
const result = AdminPingOutput.parse(valid);
|
||||
expect(result.pong).toBe(true);
|
||||
expect(result.timestamp).toBe("2024-06-01T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("parses with timezone offset timestamp", { timeout: 5000 }, () => {
|
||||
const result = AdminPingOutput.parse({
|
||||
...valid,
|
||||
timestamp: "2024-06-01T12:00:00+02:00",
|
||||
});
|
||||
expect(result.timestamp).toBe("2024-06-01T12:00:00+02:00");
|
||||
});
|
||||
|
||||
it("rejects pong: false", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminPingOutput.parse({ ...valid, pong: false })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects pong as string", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminPingOutput.parse({ ...valid, pong: "true" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid timestamp format", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminPingOutput.parse({ ...valid, timestamp: "2024-06-01" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing timestamp", { timeout: 5000 }, () => {
|
||||
expect(() => AdminPingOutput.parse({ pong: true })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing pong", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminPingOutput.parse({ timestamp: "2024-06-01T12:00:00Z" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
22
packages/api-stack/api/api-schemas/src/admin/ping.ts
Normal file
22
packages/api-stack/api/api-schemas/src/admin/ping.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for the admin ping request body.
|
||||
* The ping endpoint requires no input — this is an empty object schema.
|
||||
*/
|
||||
export const AdminPingInput = z.object({});
|
||||
|
||||
export type AdminPingInput = z.infer<typeof AdminPingInput>;
|
||||
|
||||
/**
|
||||
* Schema for the admin ping response body.
|
||||
* Returns a literal `pong: true` and the current server timestamp.
|
||||
*/
|
||||
export const AdminPingOutput = z.object({
|
||||
/** Literal marker confirming the endpoint is reachable. Always `true`. */
|
||||
pong: z.literal(true),
|
||||
/** ISO 8601 server timestamp at the time the ping was processed. */
|
||||
timestamp: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
export type AdminPingOutput = z.infer<typeof AdminPingOutput>;
|
||||
60
packages/api-stack/api/api-schemas/src/admin/status.md
Normal file
60
packages/api-stack/api/api-schemas/src/admin/status.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# status
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod schema for the admin status endpoint (`GET /admin/status`), which exposes static deployment metadata for debugging, support, and operational dashboards. Unlike the health endpoint, the status endpoint does not probe live dependencies — it reports the deployed application version, process uptime, environment name, and Node.js runtime version. These schemas ensure the response is consistently structured across all environments.
|
||||
|
||||
## Features
|
||||
|
||||
- `AdminStatusOutput` schema — `{version, uptime, environment, nodeVersion}` with strict Zod validation
|
||||
- `uptime` validated as a non-negative number representing seconds since process start
|
||||
- All string fields validated as non-empty to prevent blank deployment metadata
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AdminStatusOutput } from "@suaveplan/api-schemas/admin";
|
||||
|
||||
const status = AdminStatusOutput.parse({
|
||||
version: "1.0.0",
|
||||
uptime: 86400,
|
||||
environment: "production",
|
||||
nodeVersion: "v22.0.0",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AdminStatusOutput } from "@suaveplan/api-schemas/admin";
|
||||
|
||||
function buildStatus(startTime: number): AdminStatusOutput {
|
||||
return AdminStatusOutput.parse({
|
||||
version: process.env["APP_VERSION"] ?? "unknown",
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
nodeVersion: process.version,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AdminStatusOutput`
|
||||
|
||||
Zod object schema — status endpoint response.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|------------------|-----------------------------------------------------|
|
||||
| `version` | `string` (min 1) | Semantic version of the deployed application |
|
||||
| `uptime` | `number` (≥ 0) | Seconds the process has been running |
|
||||
| `environment` | `string` (min 1) | Deployment environment (production, staging, etc.) |
|
||||
| `nodeVersion` | `string` (min 1) | Node.js runtime version string |
|
||||
|
||||
**Type export:** `type AdminStatusOutput = z.infer<typeof AdminStatusOutput>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `uptime` field uses floating-point seconds rather than milliseconds for human readability — `86400` (one day) is far more legible than `86400000`. The `nodeVersion` field is included so support engineers can quickly confirm whether a production issue is related to a known Node.js version bug without needing shell access. In Bun environments, `process.version` still reports the Node.js compatibility version rather than the Bun version; populate `nodeVersion` accordingly.
|
||||
61
packages/api-stack/api/api-schemas/src/admin/status.test.ts
Normal file
61
packages/api-stack/api/api-schemas/src/admin/status.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AdminStatusOutput } from "./status.js";
|
||||
|
||||
describe("AdminStatusOutput", () => {
|
||||
const valid = {
|
||||
version: "1.0.0",
|
||||
uptime: 86400,
|
||||
environment: "production",
|
||||
nodeVersion: "v22.0.0",
|
||||
};
|
||||
|
||||
it("parses valid status output", { timeout: 5000 }, () => {
|
||||
const result = AdminStatusOutput.parse(valid);
|
||||
expect(result).toEqual(valid);
|
||||
});
|
||||
|
||||
it("accepts zero uptime (just started)", { timeout: 5000 }, () => {
|
||||
const result = AdminStatusOutput.parse({ ...valid, uptime: 0 });
|
||||
expect(result.uptime).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts fractional uptime seconds", { timeout: 5000 }, () => {
|
||||
const result = AdminStatusOutput.parse({ ...valid, uptime: 1234.56 });
|
||||
expect(result.uptime).toBe(1234.56);
|
||||
});
|
||||
|
||||
it("rejects empty version", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminStatusOutput.parse({ ...valid, version: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative uptime", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminStatusOutput.parse({ ...valid, uptime: -1 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty environment", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminStatusOutput.parse({ ...valid, environment: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty nodeVersion", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminStatusOutput.parse({ ...valid, nodeVersion: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing version", { timeout: 5000 }, () => {
|
||||
const { version: _v, ...rest } = valid;
|
||||
expect(() => AdminStatusOutput.parse(rest)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-number uptime", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AdminStatusOutput.parse({ ...valid, uptime: "86400" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
18
packages/api-stack/api/api-schemas/src/admin/status.ts
Normal file
18
packages/api-stack/api/api-schemas/src/admin/status.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for the admin status response body.
|
||||
* Reports static deployment metadata useful for debugging and support.
|
||||
*/
|
||||
export const AdminStatusOutput = z.object({
|
||||
/** Semantic version string of the deployed application. */
|
||||
version: z.string().min(1),
|
||||
/** Number of seconds the process has been running. */
|
||||
uptime: z.number().min(0),
|
||||
/** Deployment environment name (e.g. "production", "staging", "development"). */
|
||||
environment: z.string().min(1),
|
||||
/** Node.js version string the server is running on. */
|
||||
nodeVersion: z.string().min(1),
|
||||
});
|
||||
|
||||
export type AdminStatusOutput = z.infer<typeof AdminStatusOutput>;
|
||||
69
packages/api-stack/api/api-schemas/src/auth/context.md
Normal file
69
packages/api-stack/api/api-schemas/src/auth/context.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# context
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `AuthContext` Zod schema representing the authenticated caller's identity as it flows through a request lifecycle. After a server middleware validates the incoming access token, it extracts the user's identifier, email, roles, and optional tenant identifier into an `AuthContext` object. This schema ensures that the shape of that context is validated on construction and typed consistently across every server plugin, middleware, and service function that consumes it.
|
||||
|
||||
## Features
|
||||
|
||||
- `AuthContext` schema — `{userId, email, roles, tenantId?}` with strict Zod validation
|
||||
- `roles` as a typed `string[]` — supports role-based access control checks without custom parsing
|
||||
- Optional `tenantId` for multi-tenant deployments — present only when the tenant scope is known
|
||||
- Non-empty validation on all string fields to prevent blank identity tokens
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AuthContext } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
// Validate the decoded JWT claims after signature verification
|
||||
const ctx = AuthContext.parse({
|
||||
userId: "user_01J8ZGCZ7ECTFAKEUID123456",
|
||||
email: "alice@example.com",
|
||||
roles: ["member"],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AuthContext } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
// Middleware that builds the AuthContext from a validated JWT
|
||||
function buildAuthContext(claims: JwtClaims): AuthContext {
|
||||
return AuthContext.parse({
|
||||
userId: claims.sub,
|
||||
email: claims.email,
|
||||
roles: claims.roles ?? [],
|
||||
tenantId: claims.tenant_id,
|
||||
});
|
||||
}
|
||||
|
||||
// RBAC helper using the context
|
||||
function requireRole(ctx: AuthContext, role: string): void {
|
||||
if (!ctx.roles.includes(role)) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AuthContext`
|
||||
|
||||
Zod object schema — authenticated request context.
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|--------------------|-----------------------------------------------------|
|
||||
| `userId` | `string` (min 1) | Unique identifier of the authenticated user |
|
||||
| `email` | `string` (email) | Email address of the authenticated user |
|
||||
| `roles` | `string[]` | Role identifiers for RBAC checks |
|
||||
| `tenantId` | `string` (optional)| Tenant scope for multi-tenant deployments |
|
||||
|
||||
**Type export:** `type AuthContext = z.infer<typeof AuthContext>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`AuthContext` does not include the raw token or session identifier intentionally — the context represents decoded, validated identity, not the credential itself. Services that need to revoke or refresh tokens should do so through a separate token store, not through the auth context. The `roles` array is left as a plain `string[]` to remain agnostic about whether roles are stored as flat strings, hierarchical paths, or RBAC/ABAC expressions; the data layer is responsible for populating it correctly.
|
||||
66
packages/api-stack/api/api-schemas/src/auth/context.test.ts
Normal file
66
packages/api-stack/api/api-schemas/src/auth/context.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AuthContext } from "./context.js";
|
||||
|
||||
describe("AuthContext", () => {
|
||||
const valid = {
|
||||
userId: "user_1",
|
||||
email: "alice@example.com",
|
||||
roles: ["member"],
|
||||
};
|
||||
|
||||
it("parses valid context without tenantId", { timeout: 5000 }, () => {
|
||||
const result = AuthContext.parse(valid);
|
||||
expect(result).toEqual(valid);
|
||||
expect(result.tenantId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses valid context with tenantId", { timeout: 5000 }, () => {
|
||||
const result = AuthContext.parse({ ...valid, tenantId: "tenant_abc" });
|
||||
expect(result.tenantId).toBe("tenant_abc");
|
||||
});
|
||||
|
||||
it("parses context with multiple roles", { timeout: 5000 }, () => {
|
||||
const result = AuthContext.parse({
|
||||
...valid,
|
||||
roles: ["admin", "editor", "viewer"],
|
||||
});
|
||||
expect(result.roles).toEqual(["admin", "editor", "viewer"]);
|
||||
});
|
||||
|
||||
it("parses context with empty roles array", { timeout: 5000 }, () => {
|
||||
const result = AuthContext.parse({ ...valid, roles: [] });
|
||||
expect(result.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects empty userId", { timeout: 5000 }, () => {
|
||||
expect(() => AuthContext.parse({ ...valid, userId: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid email", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AuthContext.parse({ ...valid, email: "not-an-email" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty tenantId", { timeout: 5000 }, () => {
|
||||
expect(() => AuthContext.parse({ ...valid, tenantId: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty string in roles array", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AuthContext.parse({ ...valid, roles: ["admin", ""] })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing userId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AuthContext.parse({ email: "alice@example.com", roles: [] })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing roles field", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AuthContext.parse({ userId: "u1", email: "alice@example.com" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
18
packages/api-stack/api/api-schemas/src/auth/context.ts
Normal file
18
packages/api-stack/api/api-schemas/src/auth/context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for the authentication context extracted from a validated access token.
|
||||
* Represents the caller's identity and permissions for the current request.
|
||||
*/
|
||||
export const AuthContext = z.object({
|
||||
/** Unique identifier of the authenticated user. */
|
||||
userId: z.string().min(1),
|
||||
/** Email address of the authenticated user. */
|
||||
email: z.string().email(),
|
||||
/** Array of role identifiers assigned to this user. */
|
||||
roles: z.array(z.string().min(1)),
|
||||
/** Optional tenant identifier for multi-tenant deployments. */
|
||||
tenantId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type AuthContext = z.infer<typeof AuthContext>;
|
||||
5
packages/api-stack/api/api-schemas/src/auth/index.ts
Normal file
5
packages/api-stack/api/api-schemas/src/auth/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { AuthContext } from "./context.js";
|
||||
export { LoginInput, LoginOutput, LoginUser } from "./login.js";
|
||||
export { LogoutInput, LogoutOutput } from "./logout.js";
|
||||
export { RefreshInput, RefreshOutput } from "./refresh.js";
|
||||
export { RegisterInput, RegisterOutput } from "./register.js";
|
||||
87
packages/api-stack/api/api-schemas/src/auth/login.md
Normal file
87
packages/api-stack/api/api-schemas/src/auth/login.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# login
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines Zod schemas for the login request and response shapes used by Suaveplan's authentication endpoints. These schemas serve as the single source of truth consumed by the server route handler for `POST /auth/login`, the React login mutation hook, and the OpenAPI document generator — ensuring every layer shares the same field names, validation rules, and TypeScript types without duplication.
|
||||
|
||||
## Features
|
||||
|
||||
- `LoginInput` schema — validates `{email, password}` credentials with RFC 5322 email validation
|
||||
- `LoginUser` embedded object schema — user identifier, email, display name, and roles array returned inside the login response
|
||||
- `LoginOutput` schema — wraps a short-lived access `token`, a long-lived `refreshToken`, and a `LoginUser` object
|
||||
- All fields non-empty with appropriate Zod refinements
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LoginInput, LoginOutput } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
// Validate the parsed request body
|
||||
const input = LoginInput.parse(req.body);
|
||||
// → { email: "user@example.com", password: "secret" }
|
||||
|
||||
// Validate the response before serialising to JSON
|
||||
const output = LoginOutput.parse({
|
||||
token: "eyJhbGciOiJFZERTQSJ9...",
|
||||
refreshToken: "rt_abc123",
|
||||
user: { id: "user_01", email: "user@example.com", name: "Alice", roles: ["member"] },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LoginInput, LoginOutput } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
async function handleLogin(rawBody: unknown): Promise<LoginOutput> {
|
||||
const { email, password } = LoginInput.parse(rawBody);
|
||||
const user = await authService.verifyPassword(email, password);
|
||||
const token = await tokenService.issueAccessToken(user.id);
|
||||
const refreshToken = await tokenService.issueRefreshToken(user.id);
|
||||
return LoginOutput.parse({ token, refreshToken, user });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `LoginInput`
|
||||
|
||||
Zod object schema — login request body.
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|---------------------|---------------------------------|
|
||||
| `email` | `string` (email) | RFC 5322 valid email address |
|
||||
| `password` | `string` (min 1) | Plaintext password (TLS-only) |
|
||||
|
||||
**Type export:** `type LoginInput = z.infer<typeof LoginInput>`
|
||||
|
||||
### `LoginUser`
|
||||
|
||||
Zod object schema — user payload embedded in the login response.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|--------------------|------------------------------------------|
|
||||
| `id` | `string` (min 1) | Unique user identifier |
|
||||
| `email` | `string` (email) | User email address |
|
||||
| `name` | `string` (min 1) | Display name |
|
||||
| `roles` | `string[]` | Array of role identifiers for this user |
|
||||
|
||||
**Type export:** `type LoginUser = z.infer<typeof LoginUser>`
|
||||
|
||||
### `LoginOutput`
|
||||
|
||||
Zod object schema — login success response.
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|-----------------|-------------------------------------------|
|
||||
| `token` | `string` (min 1)| Short-lived access token |
|
||||
| `refreshToken` | `string` (min 1)| Long-lived refresh token |
|
||||
| `user` | `LoginUser` | Authenticated user details |
|
||||
|
||||
**Type export:** `type LoginOutput = z.infer<typeof LoginOutput>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `password` field uses `z.string().min(1)` rather than a minimum-length policy constraint because the schema layer validates wire format, not security policy. Minimum password length requirements belong in the registration flow and are enforced by the auth service. The access token format (JWT, opaque, etc.) is also not validated here — `z.string().min(1)` accepts any non-empty token string, keeping the schema decoupled from the token implementation.
|
||||
111
packages/api-stack/api/api-schemas/src/auth/login.test.ts
Normal file
111
packages/api-stack/api/api-schemas/src/auth/login.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LoginInput, LoginOutput, LoginUser } from "./login.js";
|
||||
|
||||
describe("LoginInput", () => {
|
||||
it("parses valid credentials", { timeout: 5000 }, () => {
|
||||
const result = LoginInput.parse({
|
||||
email: "user@example.com",
|
||||
password: "secret123",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
email: "user@example.com",
|
||||
password: "secret123",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid email", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
LoginInput.parse({ email: "not-an-email", password: "pass" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty password", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
LoginInput.parse({ email: "user@example.com", password: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing email", { timeout: 5000 }, () => {
|
||||
expect(() => LoginInput.parse({ password: "pass" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing password", { timeout: 5000 }, () => {
|
||||
expect(() => LoginInput.parse({ email: "user@example.com" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoginUser", () => {
|
||||
const validUser = {
|
||||
id: "user_1",
|
||||
email: "user@example.com",
|
||||
name: "Alice",
|
||||
roles: ["member"],
|
||||
};
|
||||
|
||||
it("parses valid user", { timeout: 5000 }, () => {
|
||||
const result = LoginUser.parse(validUser);
|
||||
expect(result).toEqual(validUser);
|
||||
});
|
||||
|
||||
it("parses user with multiple roles", { timeout: 5000 }, () => {
|
||||
const result = LoginUser.parse({
|
||||
...validUser,
|
||||
roles: ["admin", "editor"],
|
||||
});
|
||||
expect(result.roles).toEqual(["admin", "editor"]);
|
||||
});
|
||||
|
||||
it("parses user with empty roles array", { timeout: 5000 }, () => {
|
||||
const result = LoginUser.parse({ ...validUser, roles: [] });
|
||||
expect(result.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() => LoginUser.parse({ ...validUser, id: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid email in user", { timeout: 5000 }, () => {
|
||||
expect(() => LoginUser.parse({ ...validUser, email: "bad" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty name", { timeout: 5000 }, () => {
|
||||
expect(() => LoginUser.parse({ ...validUser, name: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoginOutput", () => {
|
||||
const validOutput = {
|
||||
token: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
user: {
|
||||
id: "user_1",
|
||||
email: "user@example.com",
|
||||
name: "Alice",
|
||||
roles: ["member"],
|
||||
},
|
||||
};
|
||||
|
||||
it("parses valid login output", { timeout: 5000 }, () => {
|
||||
const result = LoginOutput.parse(validOutput);
|
||||
expect(result.token).toBe("access-token");
|
||||
expect(result.user.id).toBe("user_1");
|
||||
});
|
||||
|
||||
it("rejects empty token", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
LoginOutput.parse({ ...validOutput, token: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty refreshToken", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
LoginOutput.parse({ ...validOutput, refreshToken: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing user", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
LoginOutput.parse({ token: "t", refreshToken: "r" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
45
packages/api-stack/api/api-schemas/src/auth/login.ts
Normal file
45
packages/api-stack/api/api-schemas/src/auth/login.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for the login request body.
|
||||
* Accepts an email/password credential pair.
|
||||
*/
|
||||
export const LoginInput = z.object({
|
||||
/** Email address of the authenticating user. */
|
||||
email: z.string().email(),
|
||||
/** Plaintext password submitted by the user (transmitted only over TLS). */
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export type LoginInput = z.infer<typeof LoginInput>;
|
||||
|
||||
/**
|
||||
* Schema for the embedded user object returned within a login response.
|
||||
*/
|
||||
export const LoginUser = z.object({
|
||||
/** Unique identifier of the authenticated user. */
|
||||
id: z.string().min(1),
|
||||
/** Email address of the authenticated user. */
|
||||
email: z.string().email(),
|
||||
/** Display name of the authenticated user. */
|
||||
name: z.string().min(1),
|
||||
/** Array of role identifiers assigned to this user. */
|
||||
roles: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
export type LoginUser = z.infer<typeof LoginUser>;
|
||||
|
||||
/**
|
||||
* Schema for the successful login response body.
|
||||
* Returns a short-lived access token, a long-lived refresh token, and the user object.
|
||||
*/
|
||||
export const LoginOutput = z.object({
|
||||
/** Short-lived JWT or opaque access token. */
|
||||
token: z.string().min(1),
|
||||
/** Long-lived refresh token for obtaining new access tokens. */
|
||||
refreshToken: z.string().min(1),
|
||||
/** Authenticated user object. */
|
||||
user: LoginUser,
|
||||
});
|
||||
|
||||
export type LoginOutput = z.infer<typeof LoginOutput>;
|
||||
65
packages/api-stack/api/api-schemas/src/auth/logout.md
Normal file
65
packages/api-stack/api/api-schemas/src/auth/logout.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# logout
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas for the logout request and response shapes used by Suaveplan's `POST /auth/logout` endpoint. Logout is a server-side operation: the client sends its refresh token so the server can revoke it, preventing silent background re-authentication after the user has explicitly signed out. These schemas are shared between the server route handler, React logout mutation hooks, and OpenAPI document generation.
|
||||
|
||||
## Features
|
||||
|
||||
- `LogoutInput` schema — accepts a `refreshToken` string (non-empty) for server-side revocation
|
||||
- `LogoutOutput` schema — returns `{success: true}` as a typed literal, making the success state unambiguous in TypeScript
|
||||
- `success` field uses `z.literal(true)` rather than `z.boolean()` so TypeScript narrows the type to `true` rather than `boolean`
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LogoutInput, LogoutOutput } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
// Validate the request body
|
||||
const input = LogoutInput.parse(req.body);
|
||||
// → { refreshToken: "rt_abc123" }
|
||||
|
||||
// Validate the response
|
||||
const output = LogoutOutput.parse({ success: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LogoutInput, LogoutOutput } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
async function handleLogout(rawBody: unknown): Promise<LogoutOutput> {
|
||||
const { refreshToken } = LogoutInput.parse(rawBody);
|
||||
await sessionStore.revoke(refreshToken);
|
||||
// Also clear access token from an allowlist if using stateful tokens
|
||||
return LogoutOutput.parse({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `LogoutInput`
|
||||
|
||||
Zod object schema — logout request body.
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|------------------|---------------------------------------------------|
|
||||
| `refreshToken` | `string` (min 1) | Refresh token to revoke on the server |
|
||||
|
||||
**Type export:** `type LogoutInput = z.infer<typeof LogoutInput>`
|
||||
|
||||
### `LogoutOutput`
|
||||
|
||||
Zod object schema — logout success response.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|----------------|------------------------------------------------|
|
||||
| `success` | `true` (literal)| Always `true` on a successful logout |
|
||||
|
||||
**Type export:** `type LogoutOutput = z.infer<typeof LogoutOutput>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Using `z.literal(true)` rather than `z.boolean()` means TypeScript will infer `success: true` rather than `success: boolean`, enabling exhaustiveness checks in client code that branches on the success field. Logout endpoints conventionally return 204 No Content with no body, but Suaveplan uses a 200 with a JSON body to accommodate clients (such as React hooks) that expect a typed response. Clients that receive a 401 or 404 should still clear local tokens regardless of the server response.
|
||||
40
packages/api-stack/api/api-schemas/src/auth/logout.test.ts
Normal file
40
packages/api-stack/api/api-schemas/src/auth/logout.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LogoutInput, LogoutOutput } from "./logout.js";
|
||||
|
||||
describe("LogoutInput", () => {
|
||||
it("parses valid refresh token", { timeout: 5000 }, () => {
|
||||
const result = LogoutInput.parse({ refreshToken: "rt_xyz789" });
|
||||
expect(result).toEqual({ refreshToken: "rt_xyz789" });
|
||||
});
|
||||
|
||||
it("rejects empty refresh token", { timeout: 5000 }, () => {
|
||||
expect(() => LogoutInput.parse({ refreshToken: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing refreshToken", { timeout: 5000 }, () => {
|
||||
expect(() => LogoutInput.parse({})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-string refreshToken", { timeout: 5000 }, () => {
|
||||
expect(() => LogoutInput.parse({ refreshToken: null })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogoutOutput", () => {
|
||||
it("parses {success: true}", { timeout: 5000 }, () => {
|
||||
const result = LogoutOutput.parse({ success: true });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("rejects {success: false}", { timeout: 5000 }, () => {
|
||||
expect(() => LogoutOutput.parse({ success: false })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects success as string", { timeout: 5000 }, () => {
|
||||
expect(() => LogoutOutput.parse({ success: "true" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing success field", { timeout: 5000 }, () => {
|
||||
expect(() => LogoutOutput.parse({})).toThrow();
|
||||
});
|
||||
});
|
||||
23
packages/api-stack/api/api-schemas/src/auth/logout.ts
Normal file
23
packages/api-stack/api/api-schemas/src/auth/logout.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for the logout request body.
|
||||
* The client should supply the refresh token to allow server-side revocation.
|
||||
*/
|
||||
export const LogoutInput = z.object({
|
||||
/** Refresh token to revoke on the server. */
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export type LogoutInput = z.infer<typeof LogoutInput>;
|
||||
|
||||
/**
|
||||
* Schema for the logout response body.
|
||||
* Confirms the server successfully revoked the session.
|
||||
*/
|
||||
export const LogoutOutput = z.object({
|
||||
/** Indicates whether the logout was successfully processed. Always true on success. */
|
||||
success: z.literal(true),
|
||||
});
|
||||
|
||||
export type LogoutOutput = z.infer<typeof LogoutOutput>;
|
||||
67
packages/api-stack/api/api-schemas/src/auth/refresh.md
Normal file
67
packages/api-stack/api/api-schemas/src/auth/refresh.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# refresh
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines Zod schemas for the token-refresh request and response shapes used by Suaveplan's `POST /auth/refresh` endpoint. The refresh flow implements token rotation — each use of a refresh token returns both a new access token and a new refresh token, invalidating the consumed one. These schemas are shared between the server route handler, the React auth hook that silently re-authenticates expiring sessions, and the OpenAPI generator.
|
||||
|
||||
## Features
|
||||
|
||||
- `RefreshInput` schema — accepts a single `refreshToken` string (non-empty)
|
||||
- `RefreshOutput` schema — returns a new `token` (access) and new `refreshToken` (rotation pattern)
|
||||
- Non-empty validation on both token fields to prevent silent empty-string bypasses
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RefreshInput, RefreshOutput } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
// Validate the request body
|
||||
const input = RefreshInput.parse(req.body);
|
||||
// → { refreshToken: "rt_abc123..." }
|
||||
|
||||
// Validate the response
|
||||
const output = RefreshOutput.parse({ token: "new-access-token", refreshToken: "new-rt_xyz" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RefreshInput, RefreshOutput } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
async function handleRefresh(rawBody: unknown): Promise<RefreshOutput> {
|
||||
const { refreshToken } = RefreshInput.parse(rawBody);
|
||||
const session = await sessionStore.consume(refreshToken); // invalidates old token
|
||||
const newAccess = await tokenService.issueAccessToken(session.userId);
|
||||
const newRefresh = await sessionStore.issue(session.userId);
|
||||
return RefreshOutput.parse({ token: newAccess, refreshToken: newRefresh });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `RefreshInput`
|
||||
|
||||
Zod object schema — token refresh request body.
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|------------------|---------------------------------------------------|
|
||||
| `refreshToken` | `string` (min 1) | Long-lived refresh token from a prior login/refresh|
|
||||
|
||||
**Type export:** `type RefreshInput = z.infer<typeof RefreshInput>`
|
||||
|
||||
### `RefreshOutput`
|
||||
|
||||
Zod object schema — token refresh success response.
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|------------------|------------------------------------------------|
|
||||
| `token` | `string` (min 1) | New short-lived access token |
|
||||
| `refreshToken` | `string` (min 1) | New long-lived refresh token (rotation) |
|
||||
|
||||
**Type export:** `type RefreshOutput = z.infer<typeof RefreshOutput>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The output deliberately echoes the same shape as `LoginOutput`'s token fields, allowing React hooks to handle both login and refresh responses through a shared token-update function. Token rotation (issuing a new refresh token each time) is recommended by OAuth 2.0 Security Best Practices to limit the damage from a stolen refresh token. The server should record and compare the previous refresh token before issuing the new pair, rejecting any reuse attempts.
|
||||
48
packages/api-stack/api/api-schemas/src/auth/refresh.test.ts
Normal file
48
packages/api-stack/api/api-schemas/src/auth/refresh.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RefreshInput, RefreshOutput } from "./refresh.js";
|
||||
|
||||
describe("RefreshInput", () => {
|
||||
it("parses valid refresh token", { timeout: 5000 }, () => {
|
||||
const result = RefreshInput.parse({ refreshToken: "rt_abc123" });
|
||||
expect(result).toEqual({ refreshToken: "rt_abc123" });
|
||||
});
|
||||
|
||||
it("rejects empty refresh token", { timeout: 5000 }, () => {
|
||||
expect(() => RefreshInput.parse({ refreshToken: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing refreshToken field", { timeout: 5000 }, () => {
|
||||
expect(() => RefreshInput.parse({})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-string refreshToken", { timeout: 5000 }, () => {
|
||||
expect(() => RefreshInput.parse({ refreshToken: 123 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RefreshOutput", () => {
|
||||
const valid = { token: "new-access", refreshToken: "new-refresh" };
|
||||
|
||||
it("parses valid refresh output", { timeout: 5000 }, () => {
|
||||
const result = RefreshOutput.parse(valid);
|
||||
expect(result).toEqual(valid);
|
||||
});
|
||||
|
||||
it("rejects empty token", { timeout: 5000 }, () => {
|
||||
expect(() => RefreshOutput.parse({ ...valid, token: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty refreshToken in output", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RefreshOutput.parse({ ...valid, refreshToken: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing token", { timeout: 5000 }, () => {
|
||||
expect(() => RefreshOutput.parse({ refreshToken: "rt" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing refreshToken in output", { timeout: 5000 }, () => {
|
||||
expect(() => RefreshOutput.parse({ token: "t" })).toThrow();
|
||||
});
|
||||
});
|
||||
25
packages/api-stack/api/api-schemas/src/auth/refresh.ts
Normal file
25
packages/api-stack/api/api-schemas/src/auth/refresh.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for the token refresh request body.
|
||||
* Accepts the long-lived refresh token issued at login.
|
||||
*/
|
||||
export const RefreshInput = z.object({
|
||||
/** Long-lived refresh token previously issued by the login or refresh endpoint. */
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export type RefreshInput = z.infer<typeof RefreshInput>;
|
||||
|
||||
/**
|
||||
* Schema for the successful token refresh response body.
|
||||
* Issues a new access token and a new refresh token (token rotation).
|
||||
*/
|
||||
export const RefreshOutput = z.object({
|
||||
/** New short-lived access token. */
|
||||
token: z.string().min(1),
|
||||
/** New long-lived refresh token (replaces the one used in the request). */
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export type RefreshOutput = z.infer<typeof RefreshOutput>;
|
||||
74
packages/api-stack/api/api-schemas/src/auth/register.md
Normal file
74
packages/api-stack/api/api-schemas/src/auth/register.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# register
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas for the user registration request and response shapes used by Suaveplan's `POST /auth/register` endpoint. These schemas are the single source of truth for the server route validator, the React registration mutation hook, and the OpenAPI document generator, ensuring that field names, length constraints, and TypeScript types are consistent across every layer without duplication.
|
||||
|
||||
## Features
|
||||
|
||||
- `RegisterInput` schema — validates email (RFC 5322), password (min 8 chars), and display name (1–200 chars)
|
||||
- `RegisterOutput` schema — returns the new user's `id`, `email`, and ISO 8601 `createdAt` timestamp
|
||||
- Password minimum-length policy applied at schema layer (8 characters)
|
||||
- Name length capped at 200 characters to prevent database overflow
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RegisterInput } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
const input = RegisterInput.parse({
|
||||
email: "alice@example.com",
|
||||
password: "securePassword1",
|
||||
name: "Alice Smith",
|
||||
});
|
||||
// → { email: "alice@example.com", password: "securePassword1", name: "Alice Smith" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RegisterInput, RegisterOutput } from "@suaveplan/api-schemas/auth";
|
||||
|
||||
async function handleRegister(rawBody: unknown): Promise<RegisterOutput> {
|
||||
const { email, password, name } = RegisterInput.parse(rawBody);
|
||||
const hashedPassword = await crypto.hashPassword(password);
|
||||
const user = await db.user.create({ data: { email, passwordHash: hashedPassword, name } });
|
||||
return RegisterOutput.parse({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `RegisterInput`
|
||||
|
||||
Zod object schema — registration request body.
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|---------------------------|----------------------------------------------|
|
||||
| `email` | `string` (email) | RFC 5322 valid email address |
|
||||
| `password` | `string` (min 8) | Plaintext password; minimum 8 characters |
|
||||
| `name` | `string` (min 1, max 200) | Display name for the new user |
|
||||
|
||||
**Type export:** `type RegisterInput = z.infer<typeof RegisterInput>`
|
||||
|
||||
### `RegisterOutput`
|
||||
|
||||
Zod object schema — successful registration response.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|----------------------------------|----------------------------------------|
|
||||
| `id` | `string` (min 1) | Newly created user identifier |
|
||||
| `email` | `string` (email) | Email address of the new user |
|
||||
| `createdAt` | `string` (ISO 8601 with offset) | Account creation timestamp |
|
||||
|
||||
**Type export:** `type RegisterOutput = z.infer<typeof RegisterOutput>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 8-character minimum on `password` is a deliberate schema-level policy choice for Suaveplan, aligning with NIST SP 800-63B guidance. Password complexity (uppercase, digits, special chars) is not enforced here — entropy-based policies are the responsibility of the auth service. The `createdAt` field uses `z.string().datetime({ offset: true })` rather than `z.date()` because JSON serialisation produces strings, not `Date` objects, and the schema layer should validate what actually travels over the wire.
|
||||
90
packages/api-stack/api/api-schemas/src/auth/register.test.ts
Normal file
90
packages/api-stack/api/api-schemas/src/auth/register.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RegisterInput, RegisterOutput } from "./register.js";
|
||||
|
||||
describe("RegisterInput", () => {
|
||||
const valid = {
|
||||
email: "alice@example.com",
|
||||
password: "securePass1",
|
||||
name: "Alice",
|
||||
};
|
||||
|
||||
it("parses valid registration input", { timeout: 5000 }, () => {
|
||||
const result = RegisterInput.parse(valid);
|
||||
expect(result).toEqual(valid);
|
||||
});
|
||||
|
||||
it("rejects invalid email", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterInput.parse({ ...valid, email: "not-email" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects password shorter than 8 chars", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterInput.parse({ ...valid, password: "short" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts password exactly 8 chars", { timeout: 5000 }, () => {
|
||||
const result = RegisterInput.parse({ ...valid, password: "12345678" });
|
||||
expect(result.password).toBe("12345678");
|
||||
});
|
||||
|
||||
it("rejects empty name", { timeout: 5000 }, () => {
|
||||
expect(() => RegisterInput.parse({ ...valid, name: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects name longer than 200 chars", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterInput.parse({ ...valid, name: "a".repeat(201) })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts name exactly 200 chars", { timeout: 5000 }, () => {
|
||||
const result = RegisterInput.parse({ ...valid, name: "a".repeat(200) });
|
||||
expect(result.name).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("rejects missing email", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterInput.parse({ password: "pass1234", name: "Alice" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegisterOutput", () => {
|
||||
const valid = {
|
||||
id: "user_1",
|
||||
email: "alice@example.com",
|
||||
createdAt: "2024-01-15T12:00:00Z",
|
||||
};
|
||||
|
||||
it("parses valid registration output", { timeout: 5000 }, () => {
|
||||
const result = RegisterOutput.parse(valid);
|
||||
expect(result).toEqual(valid);
|
||||
});
|
||||
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() => RegisterOutput.parse({ ...valid, id: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid email in output", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterOutput.parse({ ...valid, email: "bad" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid createdAt format", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
RegisterOutput.parse({ ...valid, createdAt: "2024-01-15" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts createdAt with timezone offset", { timeout: 5000 }, () => {
|
||||
const result = RegisterOutput.parse({
|
||||
...valid,
|
||||
createdAt: "2024-01-15T12:00:00+05:30",
|
||||
});
|
||||
expect(result.createdAt).toBe("2024-01-15T12:00:00+05:30");
|
||||
});
|
||||
});
|
||||
34
packages/api-stack/api/api-schemas/src/auth/register.ts
Normal file
34
packages/api-stack/api/api-schemas/src/auth/register.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for the user registration request body.
|
||||
* Accepts email, password, and display name for new account creation.
|
||||
*/
|
||||
export const RegisterInput = z.object({
|
||||
/** Email address for the new account. Must be unique in the system. */
|
||||
email: z.string().email(),
|
||||
/**
|
||||
* Plaintext password for the new account.
|
||||
* Minimum 8 characters enforced at the schema layer.
|
||||
*/
|
||||
password: z.string().min(8),
|
||||
/** Display name shown in the UI and notifications. */
|
||||
name: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
export type RegisterInput = z.infer<typeof RegisterInput>;
|
||||
|
||||
/**
|
||||
* Schema for the successful registration response body.
|
||||
* Returns the newly created user's identifier, email, and creation timestamp.
|
||||
*/
|
||||
export const RegisterOutput = z.object({
|
||||
/** Unique identifier of the newly created user. */
|
||||
id: z.string().min(1),
|
||||
/** Email address of the newly created user. */
|
||||
email: z.string().email(),
|
||||
/** ISO 8601 timestamp of when the account was created. */
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
export type RegisterOutput = z.infer<typeof RegisterOutput>;
|
||||
@@ -0,0 +1,91 @@
|
||||
# error-envelope
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines Zod schemas for the standard error response envelope used across all Suaveplan API endpoints. By sharing these schemas between server route handlers, React error-boundary hooks, and OpenAPI generators, every layer of the stack interprets error responses identically — no per-client parsing logic, no mismatched field names, and consistent TypeScript types end-to-end.
|
||||
|
||||
## Features
|
||||
|
||||
- `ErrorEnvelope` schema — `{code, message, details?}` base shape: stable machine-readable code, human-readable message, and an open-typed optional details field
|
||||
- `FieldError` schema — per-field validation error descriptor with dot-notation field path, message, and optional code; suitable for populating `details` on 422 responses
|
||||
- `ValidationErrorEnvelope` schema — extends `ErrorEnvelope` with `details: FieldError[]` for typed 422 Unprocessable Entity responses
|
||||
- Stable `code` strings enable programmatic error handling without string matching on `message`
|
||||
- `details` typed as `z.unknown()` in the base `ErrorEnvelope` to remain flexible; `ValidationErrorEnvelope` provides the typed narrowing for validation failures
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ErrorEnvelope } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Parse an error response body from a failed API call
|
||||
const error = ErrorEnvelope.parse({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
message: "The access token has expired.",
|
||||
});
|
||||
// → { code: "AUTH_TOKEN_EXPIRED", message: "The access token has expired." }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ValidationErrorEnvelope } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Parse a 422 response body with per-field errors
|
||||
const validationError = ValidationErrorEnvelope.parse({
|
||||
code: "VALIDATION_FAILED",
|
||||
message: "Request body failed validation.",
|
||||
details: [
|
||||
{ field: "email", message: "Must be a valid email address.", code: "INVALID_EMAIL" },
|
||||
{ field: "password", message: "Must be at least 8 characters.", code: "TOO_SHORT" },
|
||||
],
|
||||
});
|
||||
|
||||
// Display field errors in a React form
|
||||
for (const fieldErr of validationError.details) {
|
||||
console.log(`${fieldErr.field}: ${fieldErr.message}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ErrorEnvelope`
|
||||
|
||||
Zod object schema — base error shape for all API error responses.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|--------------------|--------------------------------------------------------|
|
||||
| `code` | `string` (min 1) | Stable machine-readable error code |
|
||||
| `message` | `string` (min 1) | Human-readable error description |
|
||||
| `details` | `unknown` | Optional structured context (field errors, sub-errors)|
|
||||
|
||||
**Type export:** `type ErrorEnvelope = z.infer<typeof ErrorEnvelope>`
|
||||
|
||||
### `FieldError`
|
||||
|
||||
Zod object schema — per-field validation failure descriptor.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|-------------------|--------------------------------------------------|
|
||||
| `field` | `string` (min 1) | Dot-notation path to the offending field |
|
||||
| `message` | `string` (min 1) | Human-readable validation failure description |
|
||||
| `code` | `string` | Optional machine-readable field-error code |
|
||||
|
||||
**Type export:** `type FieldError = z.infer<typeof FieldError>`
|
||||
|
||||
### `ValidationErrorEnvelope`
|
||||
|
||||
Extends `ErrorEnvelope` — typed 422 response envelope.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|-----------------|------------------------------------------|
|
||||
| `code` | `string` | Inherited from `ErrorEnvelope` |
|
||||
| `message` | `string` | Inherited from `ErrorEnvelope` |
|
||||
| `details` | `FieldError[]` | Array of per-field validation errors |
|
||||
|
||||
**Type export:** `type ValidationErrorEnvelope = z.infer<typeof ValidationErrorEnvelope>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The base `ErrorEnvelope.details` field is typed as `z.unknown()` to support the full range of structured error payloads that Suaveplan endpoints may return — not just field validation errors but also rate-limit metadata, conflict information, or retry-after hints. Callers that know they are dealing with a validation error should narrow to `ValidationErrorEnvelope` rather than relying on `details` being `unknown`.
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ErrorEnvelope,
|
||||
FieldError,
|
||||
ValidationErrorEnvelope,
|
||||
} from "./error-envelope.js";
|
||||
|
||||
describe("ErrorEnvelope", () => {
|
||||
it("parses minimal valid envelope (no details)", { timeout: 5000 }, () => {
|
||||
const result = ErrorEnvelope.parse({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found.",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found.",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses envelope with string details", { timeout: 5000 }, () => {
|
||||
const result = ErrorEnvelope.parse({
|
||||
code: "ERR",
|
||||
message: "msg",
|
||||
details: "extra",
|
||||
});
|
||||
expect(result.details).toBe("extra");
|
||||
});
|
||||
|
||||
it("parses envelope with object details", { timeout: 5000 }, () => {
|
||||
const result = ErrorEnvelope.parse({
|
||||
code: "ERR",
|
||||
message: "msg",
|
||||
details: { retryAfter: 30 },
|
||||
});
|
||||
expect(result.details).toEqual({ retryAfter: 30 });
|
||||
});
|
||||
|
||||
it("rejects empty code", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ErrorEnvelope.parse({ code: "", message: "msg" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty message", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ErrorEnvelope.parse({ code: "ERR", message: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing code", { timeout: 5000 }, () => {
|
||||
expect(() => ErrorEnvelope.parse({ message: "msg" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing message", { timeout: 5000 }, () => {
|
||||
expect(() => ErrorEnvelope.parse({ code: "ERR" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FieldError", () => {
|
||||
it("parses valid field error without code", { timeout: 5000 }, () => {
|
||||
const result = FieldError.parse({
|
||||
field: "email",
|
||||
message: "Invalid email.",
|
||||
});
|
||||
expect(result).toEqual({ field: "email", message: "Invalid email." });
|
||||
});
|
||||
|
||||
it("parses valid field error with code", { timeout: 5000 }, () => {
|
||||
const result = FieldError.parse({
|
||||
field: "email",
|
||||
message: "Invalid email.",
|
||||
code: "INVALID_EMAIL",
|
||||
});
|
||||
expect(result.code).toBe("INVALID_EMAIL");
|
||||
});
|
||||
|
||||
it("parses dot-notation field path", { timeout: 5000 }, () => {
|
||||
const result = FieldError.parse({
|
||||
field: "user.email",
|
||||
message: "Required.",
|
||||
});
|
||||
expect(result.field).toBe("user.email");
|
||||
});
|
||||
|
||||
it("rejects empty field", { timeout: 5000 }, () => {
|
||||
expect(() => FieldError.parse({ field: "", message: "msg" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing field", { timeout: 5000 }, () => {
|
||||
expect(() => FieldError.parse({ message: "msg" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty message", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
FieldError.parse({ field: "email", message: "" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ValidationErrorEnvelope", () => {
|
||||
it("parses valid validation error envelope", { timeout: 5000 }, () => {
|
||||
const result = ValidationErrorEnvelope.parse({
|
||||
code: "VALIDATION_FAILED",
|
||||
message: "Validation failed.",
|
||||
details: [
|
||||
{ field: "email", message: "Invalid email." },
|
||||
{ field: "password", message: "Too short.", code: "TOO_SHORT" },
|
||||
],
|
||||
});
|
||||
expect(result.details).toHaveLength(2);
|
||||
expect(result.details[0]?.field).toBe("email");
|
||||
});
|
||||
|
||||
it("parses with empty details array", { timeout: 5000 }, () => {
|
||||
const result = ValidationErrorEnvelope.parse({
|
||||
code: "VALIDATION_FAILED",
|
||||
message: "Validation failed.",
|
||||
details: [],
|
||||
});
|
||||
expect(result.details).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects details that is not an array", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ValidationErrorEnvelope.parse({
|
||||
code: "VALIDATION_FAILED",
|
||||
message: "msg",
|
||||
details: "not an array",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid field error in details", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ValidationErrorEnvelope.parse({
|
||||
code: "VALIDATION_FAILED",
|
||||
message: "msg",
|
||||
details: [{ field: "", message: "msg" }],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for a structured API error response envelope.
|
||||
* All Suaveplan API error responses conform to this shape regardless of HTTP status code.
|
||||
*/
|
||||
export const ErrorEnvelope = z.object({
|
||||
/** Stable, machine-readable error code string (e.g. "AUTH_TOKEN_EXPIRED"). */
|
||||
code: z.string().min(1),
|
||||
/** Human-readable error message suitable for logging and developer consumption. */
|
||||
message: z.string().min(1),
|
||||
/** Optional structured details — field validation errors, sub-errors, context. */
|
||||
details: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type ErrorEnvelope = z.infer<typeof ErrorEnvelope>;
|
||||
|
||||
/**
|
||||
* Schema for a field-level validation error within an error envelope's `details`.
|
||||
* Servers can populate `details` with an array of these to describe per-field failures.
|
||||
*/
|
||||
export const FieldError = z.object({
|
||||
/** Dot-notation path to the offending field (e.g. "user.email"). */
|
||||
field: z.string().min(1),
|
||||
/** Human-readable description of the validation failure for this field. */
|
||||
message: z.string().min(1),
|
||||
/** Optional machine-readable code for this specific field error. */
|
||||
code: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FieldError = z.infer<typeof FieldError>;
|
||||
|
||||
/**
|
||||
* Schema for an error envelope whose `details` is an array of field-level errors.
|
||||
* Used for 422 Unprocessable Entity responses from validation failures.
|
||||
*/
|
||||
export const ValidationErrorEnvelope = ErrorEnvelope.extend({
|
||||
/** Array of per-field validation failures. */
|
||||
details: z.array(FieldError),
|
||||
});
|
||||
|
||||
export type ValidationErrorEnvelope = z.infer<typeof ValidationErrorEnvelope>;
|
||||
81
packages/api-stack/api/api-schemas/src/common/filter.md
Normal file
81
packages/api-stack/api/api-schemas/src/common/filter.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# filter
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines Zod schemas for filter predicate inputs used in Suaveplan list and search endpoints. These schemas provide a structured, type-safe way to express query conditions that travel from HTTP clients through server validators to data-access layers, with each layer relying on the same schema to avoid translation errors or mismatched operator enumerations.
|
||||
|
||||
## Features
|
||||
|
||||
- `FilterOperator` enum — covers equality (`eq`, `neq`), ordering (`lt`, `lte`, `gt`, `gte`), string matching (`contains`, `startsWith`, `endsWith`), set membership (`in`, `notIn`), and nullability (`isNull`, `isNotNull`)
|
||||
- `FilterInput` object schema — a `{field, operator, value?}` triple with the value optional for unary operators
|
||||
- `FilterInputList` array schema — zero or more predicates combined with AND semantics; empty array means "no filter"
|
||||
- Dot-notation field paths supported (e.g. `"user.email"`, `"metadata.tags"`)
|
||||
- `value` typed as `z.unknown()` to remain schema-layer agnostic — data adapters perform their own value coercion
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FilterInput } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Filter records where status equals "active"
|
||||
const filter = FilterInput.parse({
|
||||
field: "status",
|
||||
operator: "eq",
|
||||
value: "active",
|
||||
});
|
||||
// → { field: "status", operator: "eq", value: "active" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FilterInputList } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// AND-joined predicates: active users whose email ends with @example.com
|
||||
const filters = FilterInputList.parse([
|
||||
{ field: "status", operator: "eq", value: "active" },
|
||||
{ field: "email", operator: "endsWith", value: "@example.com" },
|
||||
{ field: "deletedAt", operator: "isNull" },
|
||||
]);
|
||||
|
||||
// Translate to a query builder predicate list
|
||||
function applyFilters(query: SomeQueryBuilder, filters: typeof filters) {
|
||||
for (const { field, operator, value } of filters) {
|
||||
if (operator === "isNull") query.whereNull(field);
|
||||
else if (operator === "eq") query.where(field, "=", value);
|
||||
// … other operators
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FilterOperator`
|
||||
|
||||
Zod enum with values: `"eq" | "neq" | "lt" | "lte" | "gt" | "gte" | "contains" | "startsWith" | "endsWith" | "in" | "notIn" | "isNull" | "isNotNull"`.
|
||||
|
||||
**Type export:** `type FilterOperator = z.infer<typeof FilterOperator>`
|
||||
|
||||
### `FilterInput`
|
||||
|
||||
Zod object schema.
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|------------------|----------------------------------------------------------|
|
||||
| `field` | `string` (min 1) | Dot-notation field path to filter on |
|
||||
| `operator` | `FilterOperator` | Comparison operator |
|
||||
| `value` | `unknown` | Operand; omit for `isNull`/`isNotNull` unary operators |
|
||||
|
||||
**Type export:** `type FilterInput = z.infer<typeof FilterInput>`
|
||||
|
||||
### `FilterInputList`
|
||||
|
||||
Zod array of `FilterInput`. Empty array is valid and means "apply no filter".
|
||||
|
||||
**Type export:** `type FilterInputList = z.infer<typeof FilterInputList>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `value` field uses `z.unknown()` deliberately. Specific data adapters (SQL, SurrealDB, Elasticsearch) each interpret value types differently, and this schema layer should not impose premature coercion. Adapters should validate and coerce `value` according to the target field's type and the operator semantics. For `isNull`/`isNotNull` operators, the value is meaningless and should be ignored by adapters even if accidentally supplied by a client.
|
||||
120
packages/api-stack/api/api-schemas/src/common/filter.test.ts
Normal file
120
packages/api-stack/api/api-schemas/src/common/filter.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FilterInput, FilterInputList, FilterOperator } from "./filter.js";
|
||||
|
||||
describe("FilterOperator", () => {
|
||||
const validOperators = [
|
||||
"eq",
|
||||
"neq",
|
||||
"lt",
|
||||
"lte",
|
||||
"gt",
|
||||
"gte",
|
||||
"contains",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
"in",
|
||||
"notIn",
|
||||
"isNull",
|
||||
"isNotNull",
|
||||
];
|
||||
|
||||
for (const op of validOperators) {
|
||||
it(`accepts operator "${op}"`, { timeout: 5000 }, () => {
|
||||
expect(FilterOperator.parse(op)).toBe(op);
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects unknown operator", { timeout: 5000 }, () => {
|
||||
expect(() => FilterOperator.parse("LIKE")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilterInput", () => {
|
||||
it("parses valid equality filter", { timeout: 5000 }, () => {
|
||||
const result = FilterInput.parse({
|
||||
field: "status",
|
||||
operator: "eq",
|
||||
value: "active",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
field: "status",
|
||||
operator: "eq",
|
||||
value: "active",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses filter without value (isNull)", { timeout: 5000 }, () => {
|
||||
const result = FilterInput.parse({
|
||||
field: "deletedAt",
|
||||
operator: "isNull",
|
||||
});
|
||||
expect(result.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses filter with array value (in operator)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = FilterInput.parse({
|
||||
field: "role",
|
||||
operator: "in",
|
||||
value: ["admin", "editor"],
|
||||
});
|
||||
expect(result.value).toEqual(["admin", "editor"]);
|
||||
});
|
||||
|
||||
it("parses dot-notation field path", { timeout: 5000 }, () => {
|
||||
const result = FilterInput.parse({
|
||||
field: "user.email",
|
||||
operator: "endsWith",
|
||||
value: "@test.com",
|
||||
});
|
||||
expect(result.field).toBe("user.email");
|
||||
});
|
||||
|
||||
it("rejects empty field", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
FilterInput.parse({ field: "", operator: "eq", value: "x" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing field", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
FilterInput.parse({ operator: "eq", value: "x" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing operator", { timeout: 5000 }, () => {
|
||||
expect(() => FilterInput.parse({ field: "id" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid operator", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
FilterInput.parse({
|
||||
field: "id",
|
||||
operator: "BETWEEN",
|
||||
value: [1, 10],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilterInputList", () => {
|
||||
it("parses empty array", { timeout: 5000 }, () => {
|
||||
const result = FilterInputList.parse([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses multiple predicates", { timeout: 5000 }, () => {
|
||||
const result = FilterInputList.parse([
|
||||
{ field: "status", operator: "eq", value: "active" },
|
||||
{ field: "deletedAt", operator: "isNull" },
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects non-array input", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
FilterInputList.parse({ field: "id", operator: "eq" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
46
packages/api-stack/api/api-schemas/src/common/filter.ts
Normal file
46
packages/api-stack/api/api-schemas/src/common/filter.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Enumeration of comparison operators supported in filter expressions.
|
||||
* Covers equality, ordering, string matching, and set membership.
|
||||
*/
|
||||
export const FilterOperator = z.enum([
|
||||
"eq",
|
||||
"neq",
|
||||
"lt",
|
||||
"lte",
|
||||
"gt",
|
||||
"gte",
|
||||
"contains",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
"in",
|
||||
"notIn",
|
||||
"isNull",
|
||||
"isNotNull",
|
||||
]);
|
||||
|
||||
export type FilterOperator = z.infer<typeof FilterOperator>;
|
||||
|
||||
/**
|
||||
* Schema for a single filter predicate: field, operator, and optional value.
|
||||
* For operators such as `isNull`/`isNotNull` the value is irrelevant and should be omitted.
|
||||
*/
|
||||
export const FilterInput = z.object({
|
||||
/** Dot-notation field path to filter on (e.g. "status", "user.email"). */
|
||||
field: z.string().min(1),
|
||||
/** Comparison operator to apply. */
|
||||
operator: FilterOperator,
|
||||
/** Operand for the comparison. Omit for nullability checks. */
|
||||
value: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type FilterInput = z.infer<typeof FilterInput>;
|
||||
|
||||
/**
|
||||
* Schema for an array of filter predicates joined by AND semantics.
|
||||
* An empty array means no filtering (return all records).
|
||||
*/
|
||||
export const FilterInputList = z.array(FilterInput);
|
||||
|
||||
export type FilterInputList = z.infer<typeof FilterInputList>;
|
||||
62
packages/api-stack/api/api-schemas/src/common/id-param.md
Normal file
62
packages/api-stack/api/api-schemas/src/common/id-param.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# id-param
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas for URL path parameter objects carrying resource identifiers. Rather than validating raw strings ad-hoc in every route handler, all Suaveplan endpoints that accept an `:id` segment share this schema, ensuring consistent error messages, type inference, and zero-duplication across the server plugin layer and generated OpenAPI documents.
|
||||
|
||||
## Features
|
||||
|
||||
- `IdParam` object schema — validates a single `{id: string}` path parameter (non-empty)
|
||||
- `NestedIdParam` object schema — validates `{parentId, id}` pairs for nested resource routes (e.g. `/orgs/:orgId/members/:id`)
|
||||
- Both schemas enforce non-empty strings, catching blank or missing identifiers before any database query is attempted
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { IdParam } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Validate route params from an HTTP framework
|
||||
const params = IdParam.parse({ id: "user_01J8ZGCZ7ECTFAKEUID123456" });
|
||||
// → { id: "user_01J8ZGCZ7ECTFAKEUID123456" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NestedIdParam } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Validate nested resource route params: /orgs/:parentId/members/:id
|
||||
function getMember(rawParams: unknown) {
|
||||
const { parentId, id } = NestedIdParam.parse(rawParams);
|
||||
return db.member.findUnique({ where: { id, orgId: parentId } });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `IdParam`
|
||||
|
||||
Zod object schema for single-resource routes.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------------------|------------------------------------------------|
|
||||
| `id` | `string` (min 1) | Non-empty resource identifier from URL segment |
|
||||
|
||||
**Type export:** `type IdParam = z.infer<typeof IdParam>`
|
||||
|
||||
### `NestedIdParam`
|
||||
|
||||
Zod object schema for nested-resource routes.
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|------------------|----------------------------------|
|
||||
| `parentId` | `string` (min 1) | Parent resource identifier |
|
||||
| `id` | `string` (min 1) | Child resource identifier |
|
||||
|
||||
**Type export:** `type NestedIdParam = z.infer<typeof NestedIdParam>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both schemas use `z.string().min(1)` rather than additional format constraints (UUID, ULID, etc.) to keep them format-agnostic. Suaveplan uses multiple ID formats depending on the domain (ULIDs for user-facing resources, SHA hashes for content-addressable objects, numeric sequences for legacy integrations). Services that require a specific format should compose these schemas with `.refine()` or `.regex()` at the route layer rather than tightening the shared base schema.
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { IdParam, NestedIdParam } from "./id-param.js";
|
||||
|
||||
describe("IdParam", () => {
|
||||
it("parses valid id", { timeout: 5000 }, () => {
|
||||
const result = IdParam.parse({ id: "user_123" });
|
||||
expect(result).toEqual({ id: "user_123" });
|
||||
});
|
||||
|
||||
it("accepts UUID-style id", { timeout: 5000 }, () => {
|
||||
const result = IdParam.parse({
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
});
|
||||
expect(result.id).toBe("550e8400-e29b-41d4-a716-446655440000");
|
||||
});
|
||||
|
||||
it("accepts numeric string id", { timeout: 5000 }, () => {
|
||||
const result = IdParam.parse({ id: "42" });
|
||||
expect(result.id).toBe("42");
|
||||
});
|
||||
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() => IdParam.parse({ id: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing id", { timeout: 5000 }, () => {
|
||||
expect(() => IdParam.parse({})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects numeric id (not string)", { timeout: 5000 }, () => {
|
||||
expect(() => IdParam.parse({ id: 42 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NestedIdParam", () => {
|
||||
it("parses valid parentId and id", { timeout: 5000 }, () => {
|
||||
const result = NestedIdParam.parse({
|
||||
parentId: "org_1",
|
||||
id: "member_2",
|
||||
});
|
||||
expect(result).toEqual({ parentId: "org_1", id: "member_2" });
|
||||
});
|
||||
|
||||
it("rejects empty parentId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NestedIdParam.parse({ parentId: "", id: "member_2" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NestedIdParam.parse({ parentId: "org_1", id: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing parentId", { timeout: 5000 }, () => {
|
||||
expect(() => NestedIdParam.parse({ id: "member_2" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing id", { timeout: 5000 }, () => {
|
||||
expect(() => NestedIdParam.parse({ parentId: "org_1" })).toThrow();
|
||||
});
|
||||
});
|
||||
25
packages/api-stack/api/api-schemas/src/common/id-param.ts
Normal file
25
packages/api-stack/api/api-schemas/src/common/id-param.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for a single URL path parameter carrying a resource identifier.
|
||||
* Used on endpoints such as GET /users/:id, DELETE /posts/:id, etc.
|
||||
*/
|
||||
export const IdParam = z.object({
|
||||
/** Non-empty string identifier extracted from a URL path segment. */
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export type IdParam = z.infer<typeof IdParam>;
|
||||
|
||||
/**
|
||||
* Schema for route params that include both a parent and child resource ID.
|
||||
* Useful for nested resource routes like /orgs/:orgId/members/:memberId.
|
||||
*/
|
||||
export const NestedIdParam = z.object({
|
||||
/** Parent resource identifier. */
|
||||
parentId: z.string().min(1),
|
||||
/** Child resource identifier. */
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export type NestedIdParam = z.infer<typeof NestedIdParam>;
|
||||
10
packages/api-stack/api/api-schemas/src/common/index.ts
Normal file
10
packages/api-stack/api/api-schemas/src/common/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
ErrorEnvelope,
|
||||
FieldError,
|
||||
ValidationErrorEnvelope,
|
||||
} from "./error-envelope.js";
|
||||
export { FilterInput, FilterInputList, FilterOperator } from "./filter.js";
|
||||
export { IdParam, NestedIdParam } from "./id-param.js";
|
||||
export { PaginationInput, PaginationOutput } from "./pagination.js";
|
||||
export { SortDirection, SortInput, SortInputList } from "./sort.js";
|
||||
export { SoftDeleteTimestampsOutput, TimestampsOutput } from "./timestamps.js";
|
||||
79
packages/api-stack/api/api-schemas/src/common/pagination.md
Normal file
79
packages/api-stack/api/api-schemas/src/common/pagination.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# pagination
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines Zod schemas for pagination input and output shapes used across all Suaveplan list endpoints. These schemas serve as the single source of truth that is consumed by server route handlers, React data-fetching hooks, and OpenAPI document generators alike, ensuring cursor-based and offset-based pagination are expressed identically across every layer of the stack.
|
||||
|
||||
## Features
|
||||
|
||||
- `PaginationInput` — accepts an optional 1-based `page` number, a `limit` (clamped 1–200), and an opaque `cursor` string; all fields optional so callers can omit what they don't need
|
||||
- `PaginationOutput` — carries total count (nullable when unknown), current page, limit, `hasNextPage` flag, and opaque `startCursor`/`endCursor` strings for cursor-based traversal
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Both cursor-based and offset-based strategies represented in one schema set
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PaginationInput } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Parse a query-string object coming from an HTTP request
|
||||
const params = PaginationInput.parse({ limit: 20, cursor: "eyJpZCI6IjEifQ==" });
|
||||
// → { limit: 20, cursor: "eyJpZCI6IjEifQ==" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PaginationInput, PaginationOutput } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Validate request query params with safe parse
|
||||
const result = PaginationInput.safeParse({ page: 2, limit: 50 });
|
||||
if (!result.success) {
|
||||
console.error(result.error.flatten());
|
||||
} else {
|
||||
const { page, limit } = result.data;
|
||||
// Use page/limit to run a DB query, then build PaginationOutput:
|
||||
const meta = PaginationOutput.parse({
|
||||
total: 120,
|
||||
page: 2,
|
||||
limit: 50,
|
||||
hasNextPage: true,
|
||||
startCursor: "eyJpZCI6IjUxIn0=",
|
||||
endCursor: "eyJpZCI6IjEwMCJ9",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PaginationInput`
|
||||
|
||||
Zod object schema. All fields optional.
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------|--------------------------|-------------------------------------------------------|
|
||||
| `page` | `number` (int ≥ 1) | 1-based page index; ignored when `cursor` is present |
|
||||
| `limit` | `number` (int 1–200) | Maximum items per page |
|
||||
| `cursor` | `string` (min length 1) | Opaque cursor from a prior response |
|
||||
|
||||
**Type export:** `type PaginationInput = z.infer<typeof PaginationInput>`
|
||||
|
||||
### `PaginationOutput`
|
||||
|
||||
Zod object schema. Returned as part of list responses.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|----------------------------|--------------------------------------------------|
|
||||
| `total` | `number \| null` | Total matching records; null when count is skipped|
|
||||
| `page` | `number \| null` | Current page (null for pure cursor pagination) |
|
||||
| `limit` | `number` (int ≥ 1) | Items per page |
|
||||
| `hasNextPage` | `boolean` | Whether more items follow this page |
|
||||
| `startCursor` | `string \| null` | Cursor pointing to first item in this page |
|
||||
| `endCursor` | `string \| null` | Cursor pointing to last item in this page |
|
||||
|
||||
**Type export:** `type PaginationOutput = z.infer<typeof PaginationOutput>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All fields in `PaginationInput` are optional to support partial query-string parsing in HTTP frameworks that pass unknown keys through. The `limit` field is hard-clamped to 200 to prevent accidental unbounded queries; services that require a different ceiling should create a refined schema via `.superRefine()`. The cursor is validated as a non-empty string but its internal encoding is opaque to this schema layer — encoding/decoding is the responsibility of the data adapter.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PaginationInput, PaginationOutput } from "./pagination.js";
|
||||
|
||||
describe("PaginationInput", () => {
|
||||
it("parses empty object (all fields optional)", { timeout: 5000 }, () => {
|
||||
const result = PaginationInput.parse({});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("parses valid page + limit", { timeout: 5000 }, () => {
|
||||
const result = PaginationInput.parse({ page: 1, limit: 20 });
|
||||
expect(result).toEqual({ page: 1, limit: 20 });
|
||||
});
|
||||
|
||||
it("parses valid cursor", { timeout: 5000 }, () => {
|
||||
const result = PaginationInput.parse({ cursor: "abc123", limit: 10 });
|
||||
expect(result).toEqual({ cursor: "abc123", limit: 10 });
|
||||
});
|
||||
|
||||
it("rejects page < 1", { timeout: 5000 }, () => {
|
||||
expect(() => PaginationInput.parse({ page: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects limit < 1", { timeout: 5000 }, () => {
|
||||
expect(() => PaginationInput.parse({ limit: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects limit > 200", { timeout: 5000 }, () => {
|
||||
expect(() => PaginationInput.parse({ limit: 201 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty cursor", { timeout: 5000 }, () => {
|
||||
expect(() => PaginationInput.parse({ cursor: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer page", { timeout: 5000 }, () => {
|
||||
expect(() => PaginationInput.parse({ page: 1.5 })).toThrow();
|
||||
});
|
||||
|
||||
it("accepts max limit of 200", { timeout: 5000 }, () => {
|
||||
const result = PaginationInput.parse({ limit: 200 });
|
||||
expect(result.limit).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaginationOutput", () => {
|
||||
const valid = {
|
||||
total: 100,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
hasNextPage: true,
|
||||
startCursor: "start",
|
||||
endCursor: "end",
|
||||
};
|
||||
|
||||
it("parses valid output", { timeout: 5000 }, () => {
|
||||
const result = PaginationOutput.parse(valid);
|
||||
expect(result).toEqual(valid);
|
||||
});
|
||||
|
||||
it("parses with null total and cursors", { timeout: 5000 }, () => {
|
||||
const result = PaginationOutput.parse({
|
||||
total: null,
|
||||
page: null,
|
||||
limit: 10,
|
||||
hasNextPage: false,
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
});
|
||||
expect(result.total).toBeNull();
|
||||
expect(result.startCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing required fields", { timeout: 5000 }, () => {
|
||||
expect(() => PaginationOutput.parse({})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative limit", { timeout: 5000 }, () => {
|
||||
expect(() => PaginationOutput.parse({ ...valid, limit: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-boolean hasNextPage", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
PaginationOutput.parse({ ...valid, hasNextPage: "yes" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
37
packages/api-stack/api/api-schemas/src/common/pagination.ts
Normal file
37
packages/api-stack/api/api-schemas/src/common/pagination.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for cursor-based or offset-based pagination input parameters.
|
||||
* Supports both offset/page-number style and opaque cursor style.
|
||||
*/
|
||||
export const PaginationInput = z.object({
|
||||
/** Current page number (1-based). Ignored when cursor is provided. */
|
||||
page: z.number().int().min(1).optional(),
|
||||
/** Maximum number of items to return. Clamped to 1–200. */
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
/** Opaque cursor returned by a previous page response. */
|
||||
cursor: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type PaginationInput = z.infer<typeof PaginationInput>;
|
||||
|
||||
/**
|
||||
* Schema for the pagination metadata returned in list responses.
|
||||
* Provides enough context for clients to request the next/previous page.
|
||||
*/
|
||||
export const PaginationOutput = z.object({
|
||||
/** Total number of records matching the query (may be null if unknown). */
|
||||
total: z.number().int().min(0).nullable(),
|
||||
/** Current page number (only meaningful in offset-based pagination). */
|
||||
page: z.number().int().min(1).nullable(),
|
||||
/** Number of items per page. */
|
||||
limit: z.number().int().min(1),
|
||||
/** Whether another page of results exists after this one. */
|
||||
hasNextPage: z.boolean(),
|
||||
/** Opaque cursor pointing to the first item in this page. */
|
||||
startCursor: z.string().nullable(),
|
||||
/** Opaque cursor pointing to the last item in this page. */
|
||||
endCursor: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type PaginationOutput = z.infer<typeof PaginationOutput>;
|
||||
74
packages/api-stack/api/api-schemas/src/common/sort.md
Normal file
74
packages/api-stack/api/api-schemas/src/common/sort.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# sort
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas for sort criteria used in Suaveplan list and search endpoints. By centralising the `SortInput` and `SortDirection` definitions here, every layer of the application — from REST route validators to ORM query builders to OpenAPI generators — derives its types from a single authoritative schema, preventing divergence between what the API accepts and what the database layer interprets.
|
||||
|
||||
## Features
|
||||
|
||||
- `SortDirection` enum schema — enforces only `"asc"` or `"desc"` values
|
||||
- `SortInput` object schema — a single `{field, direction}` pair with `direction` defaulting to `"asc"` for convenience
|
||||
- `SortInputList` array schema — ordered list of `SortInput` for multi-column sorts (first element has highest precedence)
|
||||
- Dot-notation field paths supported (e.g. `"user.createdAt"`)
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SortInput } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Parse a single sort criterion
|
||||
const sort = SortInput.parse({ field: "createdAt", direction: "desc" });
|
||||
// → { field: "createdAt", direction: "desc" }
|
||||
|
||||
// Direction defaults to "asc" when omitted
|
||||
const sortAsc = SortInput.parse({ field: "name" });
|
||||
// → { field: "name", direction: "asc" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SortInputList } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Multi-column sort: primary by createdAt desc, secondary by name asc
|
||||
const sorts = SortInputList.parse([
|
||||
{ field: "createdAt", direction: "desc" },
|
||||
{ field: "name" },
|
||||
]);
|
||||
|
||||
// Apply to an ORM query
|
||||
function buildOrderBy(sorts: typeof sorts) {
|
||||
return sorts.map(({ field, direction }) => ({ [field]: direction }));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SortDirection`
|
||||
|
||||
Zod enum: `"asc" | "desc"`.
|
||||
|
||||
**Type export:** `type SortDirection = z.infer<typeof SortDirection>`
|
||||
|
||||
### `SortInput`
|
||||
|
||||
Zod object schema.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|-----------------|------------------------------------------------------------|
|
||||
| `field` | `string` (min 1)| Dot-notation field path to sort on |
|
||||
| `direction` | `SortDirection` | `"asc"` or `"desc"`; defaults to `"asc"` when not provided|
|
||||
|
||||
**Type export:** `type SortInput = z.infer<typeof SortInput>`
|
||||
|
||||
### `SortInputList`
|
||||
|
||||
Zod array of `SortInput` (min length 1). First element has highest sort precedence.
|
||||
|
||||
**Type export:** `type SortInputList = z.infer<typeof SortInputList>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `direction` field uses Zod's `.default("asc")` so that it is always present on parsed output regardless of whether the client omitted it. This removes the need for downstream `?? "asc"` guard expressions. The `SortInputList` requires at least one element to prevent callers from passing an empty array, which would result in a no-op sort that silently returns database-ordered results — an ambiguous state that is better caught at the schema layer.
|
||||
77
packages/api-stack/api/api-schemas/src/common/sort.test.ts
Normal file
77
packages/api-stack/api/api-schemas/src/common/sort.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SortDirection, SortInput, SortInputList } from "./sort.js";
|
||||
|
||||
describe("SortDirection", () => {
|
||||
it("accepts asc", { timeout: 5000 }, () => {
|
||||
expect(SortDirection.parse("asc")).toBe("asc");
|
||||
});
|
||||
|
||||
it("accepts desc", { timeout: 5000 }, () => {
|
||||
expect(SortDirection.parse("desc")).toBe("desc");
|
||||
});
|
||||
|
||||
it("rejects invalid direction", { timeout: 5000 }, () => {
|
||||
expect(() => SortDirection.parse("ASC")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty string", { timeout: 5000 }, () => {
|
||||
expect(() => SortDirection.parse("")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SortInput", () => {
|
||||
it("parses valid field and direction", { timeout: 5000 }, () => {
|
||||
const result = SortInput.parse({
|
||||
field: "createdAt",
|
||||
direction: "desc",
|
||||
});
|
||||
expect(result).toEqual({ field: "createdAt", direction: "desc" });
|
||||
});
|
||||
|
||||
it("defaults direction to asc", { timeout: 5000 }, () => {
|
||||
const result = SortInput.parse({ field: "name" });
|
||||
expect(result.direction).toBe("asc");
|
||||
});
|
||||
|
||||
it("accepts dot-notation field path", { timeout: 5000 }, () => {
|
||||
const result = SortInput.parse({
|
||||
field: "user.createdAt",
|
||||
direction: "asc",
|
||||
});
|
||||
expect(result.field).toBe("user.createdAt");
|
||||
});
|
||||
|
||||
it("rejects empty field", { timeout: 5000 }, () => {
|
||||
expect(() => SortInput.parse({ field: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing field", { timeout: 5000 }, () => {
|
||||
expect(() => SortInput.parse({ direction: "asc" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid direction value", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
SortInput.parse({ field: "id", direction: "random" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SortInputList", () => {
|
||||
it("parses a single sort entry", { timeout: 5000 }, () => {
|
||||
const result = SortInputList.parse([{ field: "id", direction: "asc" }]);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses multi-column sort list", { timeout: 5000 }, () => {
|
||||
const result = SortInputList.parse([
|
||||
{ field: "createdAt", direction: "desc" },
|
||||
{ field: "name" },
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]?.direction).toBe("asc");
|
||||
});
|
||||
|
||||
it("rejects empty array", { timeout: 5000 }, () => {
|
||||
expect(() => SortInputList.parse([])).toThrow();
|
||||
});
|
||||
});
|
||||
29
packages/api-stack/api/api-schemas/src/common/sort.ts
Normal file
29
packages/api-stack/api/api-schemas/src/common/sort.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Enumeration of valid sort direction values for list queries.
|
||||
*/
|
||||
export const SortDirection = z.enum(["asc", "desc"]);
|
||||
|
||||
export type SortDirection = z.infer<typeof SortDirection>;
|
||||
|
||||
/**
|
||||
* Schema for a single sort criterion: a field name and its direction.
|
||||
* Multiple instances can be combined in an array for multi-column sorts.
|
||||
*/
|
||||
export const SortInput = z.object({
|
||||
/** Dot-notation field path to sort by (e.g. "createdAt", "user.name"). */
|
||||
field: z.string().min(1),
|
||||
/** Sort direction: ascending or descending. Defaults to "asc" when parsed. */
|
||||
direction: SortDirection.default("asc"),
|
||||
});
|
||||
|
||||
export type SortInput = z.infer<typeof SortInput>;
|
||||
|
||||
/**
|
||||
* Schema for a list of sort criteria (multi-column / multi-key sorting).
|
||||
* Items are applied in order: first element has highest precedence.
|
||||
*/
|
||||
export const SortInputList = z.array(SortInput).min(1);
|
||||
|
||||
export type SortInputList = z.infer<typeof SortInputList>;
|
||||
71
packages/api-stack/api/api-schemas/src/common/timestamps.md
Normal file
71
packages/api-stack/api/api-schemas/src/common/timestamps.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# timestamps
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines Zod schemas for the audit timestamp fields (`createdAt`, `updatedAt`, and optionally `deletedAt`) that appear on virtually every Suaveplan resource response. Centralising these schemas ensures that every endpoint returns timestamps in the same ISO 8601 wire format and that the TypeScript types derived from the schemas are consistent across server handlers, React data hooks, and OpenAPI generators.
|
||||
|
||||
## Features
|
||||
|
||||
- `TimestampsOutput` schema — `{createdAt, updatedAt}` as ISO 8601 strings with timezone offset (`z.string().datetime({ offset: true })`)
|
||||
- `SoftDeleteTimestampsOutput` schema — extends `TimestampsOutput` with a nullable `deletedAt` field for soft-delete patterns
|
||||
- Strict ISO 8601 validation with timezone offset requirement — prevents bare local-time strings
|
||||
- Composable via `z.extend()` so resource schemas can merge timestamps in without boilerplate
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TimestampsOutput } from "@suaveplan/api-schemas/common";
|
||||
|
||||
const ts = TimestampsOutput.parse({
|
||||
createdAt: "2024-01-15T12:00:00Z",
|
||||
updatedAt: "2024-06-01T09:30:00+02:00",
|
||||
});
|
||||
// → { createdAt: "2024-01-15T12:00:00Z", updatedAt: "2024-06-01T09:30:00+02:00" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { TimestampsOutput, SoftDeleteTimestampsOutput } from "@suaveplan/api-schemas/common";
|
||||
|
||||
// Merge timestamps into a resource schema
|
||||
const UserResponse = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
}).merge(SoftDeleteTimestampsOutput);
|
||||
|
||||
type UserResponse = z.infer<typeof UserResponse>;
|
||||
// → { id: string; email: string; createdAt: string; updatedAt: string; deletedAt: string | null }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TimestampsOutput`
|
||||
|
||||
Zod object schema.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|-------------------|-----------------------------------------------------|
|
||||
| `createdAt` | `string` (ISO 8601 with offset) | Resource creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601 with offset) | Most recent modification timestamp |
|
||||
|
||||
**Type export:** `type TimestampsOutput = z.infer<typeof TimestampsOutput>`
|
||||
|
||||
### `SoftDeleteTimestampsOutput`
|
||||
|
||||
Extends `TimestampsOutput`.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|----------------------------------|----------------------------------------------|
|
||||
| `createdAt` | `string` (ISO 8601 with offset) | Inherited from `TimestampsOutput` |
|
||||
| `updatedAt` | `string` (ISO 8601 with offset) | Inherited from `TimestampsOutput` |
|
||||
| `deletedAt` | `string \| null` (ISO 8601 with offset) | Soft-delete timestamp, null if not deleted |
|
||||
|
||||
**Type export:** `type SoftDeleteTimestampsOutput = z.infer<typeof SoftDeleteTimestampsOutput>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `{ offset: true }` option on `z.string().datetime()` requires that the timestamp include a UTC offset (e.g. `Z` or `+HH:MM`). This prevents bare local-time strings like `"2024-01-15T12:00:00"` from passing validation, which is important because ambiguous timestamps cause subtle bugs when crossing timezone boundaries. All Suaveplan services store and transmit timestamps in UTC or with explicit offset.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SoftDeleteTimestampsOutput, TimestampsOutput } from "./timestamps.js";
|
||||
|
||||
describe("TimestampsOutput", () => {
|
||||
it("parses valid UTC timestamps", { timeout: 5000 }, () => {
|
||||
const result = TimestampsOutput.parse({
|
||||
createdAt: "2024-01-15T12:00:00Z",
|
||||
updatedAt: "2024-06-01T09:30:00Z",
|
||||
});
|
||||
expect(result.createdAt).toBe("2024-01-15T12:00:00Z");
|
||||
expect(result.updatedAt).toBe("2024-06-01T09:30:00Z");
|
||||
});
|
||||
|
||||
it("parses timestamps with timezone offset", { timeout: 5000 }, () => {
|
||||
const result = TimestampsOutput.parse({
|
||||
createdAt: "2024-01-15T12:00:00+02:00",
|
||||
updatedAt: "2024-06-01T09:30:00-05:00",
|
||||
});
|
||||
expect(result.createdAt).toBe("2024-01-15T12:00:00+02:00");
|
||||
});
|
||||
|
||||
it("rejects bare local-time string without offset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
TimestampsOutput.parse({
|
||||
createdAt: "2024-01-15T12:00:00",
|
||||
updatedAt: "2024-06-01T09:30:00",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-datetime string", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
TimestampsOutput.parse({
|
||||
createdAt: "not-a-date",
|
||||
updatedAt: "2024-01-15T12:00:00Z",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing createdAt", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
TimestampsOutput.parse({ updatedAt: "2024-01-15T12:00:00Z" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing updatedAt", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
TimestampsOutput.parse({ createdAt: "2024-01-15T12:00:00Z" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SoftDeleteTimestampsOutput", () => {
|
||||
const base = {
|
||||
createdAt: "2024-01-15T12:00:00Z",
|
||||
updatedAt: "2024-06-01T09:30:00Z",
|
||||
};
|
||||
|
||||
it("parses with null deletedAt (not deleted)", { timeout: 5000 }, () => {
|
||||
const result = SoftDeleteTimestampsOutput.parse({
|
||||
...base,
|
||||
deletedAt: null,
|
||||
});
|
||||
expect(result.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("parses with valid deletedAt timestamp", { timeout: 5000 }, () => {
|
||||
const result = SoftDeleteTimestampsOutput.parse({
|
||||
...base,
|
||||
deletedAt: "2024-12-01T00:00:00Z",
|
||||
});
|
||||
expect(result.deletedAt).toBe("2024-12-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("rejects missing deletedAt", { timeout: 5000 }, () => {
|
||||
expect(() => SoftDeleteTimestampsOutput.parse(base)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid deletedAt format", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
SoftDeleteTimestampsOutput.parse({
|
||||
...base,
|
||||
deletedAt: "2024-12-01",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
27
packages/api-stack/api/api-schemas/src/common/timestamps.ts
Normal file
27
packages/api-stack/api/api-schemas/src/common/timestamps.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for the standard `createdAt` / `updatedAt` timestamps returned in resource responses.
|
||||
* Both fields are ISO 8601 strings — the format used over the wire in JSON.
|
||||
*/
|
||||
export const TimestampsOutput = z.object({
|
||||
/** ISO 8601 timestamp of when the resource was first created. */
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
/** ISO 8601 timestamp of the most recent modification to the resource. */
|
||||
updatedAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
export type TimestampsOutput = z.infer<typeof TimestampsOutput>;
|
||||
|
||||
/**
|
||||
* Schema that extends `TimestampsOutput` with a soft-delete timestamp.
|
||||
* `deletedAt` is null when the resource has not been deleted.
|
||||
*/
|
||||
export const SoftDeleteTimestampsOutput = TimestampsOutput.extend({
|
||||
/** ISO 8601 timestamp of when the resource was soft-deleted, or null. */
|
||||
deletedAt: z.string().datetime({ offset: true }).nullable(),
|
||||
});
|
||||
|
||||
export type SoftDeleteTimestampsOutput = z.infer<
|
||||
typeof SoftDeleteTimestampsOutput
|
||||
>;
|
||||
@@ -0,0 +1,71 @@
|
||||
# domain-event
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `DomainEvent` Zod schema — the canonical wire format for immutable domain events flowing through the Suaveplan event-sourcing and event-driven integration infrastructure. Domain events record facts about things that happened within a bounded context: "UserRegistered", "OrderPlaced", "PaymentFailed". This schema provides the structural envelope; the domain-specific payload schema is defined and validated by each aggregate separately.
|
||||
|
||||
## Features
|
||||
|
||||
- `DomainEvent` schema — `{id, type, aggregateId, aggregateType, payload, occurredAt}` fully typed
|
||||
- `type` as a dot-separated event-type name (e.g. `"user.registered"`) — convention over constraint
|
||||
- `aggregateId` + `aggregateType` together identify which aggregate instance raised the event
|
||||
- `payload` typed as `z.unknown()` — the envelope is payload-agnostic; aggregate event types carry their own Zod schema
|
||||
- `occurredAt` as an ISO 8601 string with timezone offset — the moment in domain time when the event occurred
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DomainEvent } from "@suaveplan/api-schemas/events";
|
||||
|
||||
const event = DomainEvent.parse({
|
||||
id: "evt_01J8ZGCZ7ECTFAKEUID123456",
|
||||
type: "user.registered",
|
||||
aggregateId: "user_abc123",
|
||||
aggregateType: "User",
|
||||
payload: { email: "alice@example.com", name: "Alice" },
|
||||
occurredAt: "2024-06-01T12:00:00Z",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DomainEvent } from "@suaveplan/api-schemas/events";
|
||||
import z from "zod";
|
||||
|
||||
// Narrow the payload for a specific event type
|
||||
const UserRegisteredPayload = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
function handleUserRegistered(raw: unknown) {
|
||||
const event = DomainEvent.parse(raw);
|
||||
if (event.type !== "user.registered") return;
|
||||
const payload = UserRegisteredPayload.parse(event.payload);
|
||||
console.log(`New user: ${payload.name} (${payload.email})`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DomainEvent`
|
||||
|
||||
Zod object schema — immutable domain event record.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------------|---------------------------------|------------------------------------------------------|
|
||||
| `id` | `string` (min 1) | Unique event identifier |
|
||||
| `type` | `string` (min 1) | Event type name (e.g. `"user.registered"`) |
|
||||
| `aggregateId` | `string` (min 1) | Identifier of the source aggregate |
|
||||
| `aggregateType` | `string` (min 1) | Type name of the source aggregate |
|
||||
| `payload` | `unknown` | Domain-specific event data |
|
||||
| `occurredAt` | `string` (ISO 8601 with offset) | Domain time when the event occurred |
|
||||
|
||||
**Type export:** `type DomainEvent = z.infer<typeof DomainEvent>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`payload` is `z.unknown()` intentionally — the base schema validates the event envelope, not the payload contents. Each aggregate type defines its own Zod schema for each of its event types; payload validation is performed after routing by event type. This pattern keeps the event bus loosely coupled and avoids a centralised schema registry for domain events, which would become a bottleneck in a large bounded-context landscape.
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DomainEvent } from "./domain-event.js";
|
||||
|
||||
describe("DomainEvent", () => {
|
||||
const valid = {
|
||||
id: "evt_1",
|
||||
type: "user.registered",
|
||||
aggregateId: "user_abc",
|
||||
aggregateType: "User",
|
||||
payload: { email: "alice@example.com" },
|
||||
occurredAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
|
||||
it("parses valid domain event", { timeout: 5000 }, () => {
|
||||
const result = DomainEvent.parse(valid);
|
||||
expect(result.type).toBe("user.registered");
|
||||
expect(result.payload).toEqual({ email: "alice@example.com" });
|
||||
});
|
||||
|
||||
it("parses event with null payload", { timeout: 5000 }, () => {
|
||||
const result = DomainEvent.parse({ ...valid, payload: null });
|
||||
expect(result.payload).toBeNull();
|
||||
});
|
||||
|
||||
it("parses event with array payload", { timeout: 5000 }, () => {
|
||||
const result = DomainEvent.parse({ ...valid, payload: [1, 2, 3] });
|
||||
expect(result.payload).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("parses event with numeric payload", { timeout: 5000 }, () => {
|
||||
const result = DomainEvent.parse({ ...valid, payload: 42 });
|
||||
expect(result.payload).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() => DomainEvent.parse({ ...valid, id: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty type", { timeout: 5000 }, () => {
|
||||
expect(() => DomainEvent.parse({ ...valid, type: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty aggregateId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
DomainEvent.parse({ ...valid, aggregateId: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty aggregateType", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
DomainEvent.parse({ ...valid, aggregateType: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid occurredAt format", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
DomainEvent.parse({ ...valid, occurredAt: "2024-06-01" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing occurredAt", { timeout: 5000 }, () => {
|
||||
const { occurredAt: _oa, ...rest } = valid;
|
||||
expect(() => DomainEvent.parse(rest)).toThrow();
|
||||
});
|
||||
|
||||
it("accepts occurredAt with timezone offset", { timeout: 5000 }, () => {
|
||||
const result = DomainEvent.parse({
|
||||
...valid,
|
||||
occurredAt: "2024-06-01T12:00:00+05:30",
|
||||
});
|
||||
expect(result.occurredAt).toBe("2024-06-01T12:00:00+05:30");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for a domain event — the immutable record of something that happened
|
||||
* within a bounded context. Domain events are the atomic unit of event sourcing
|
||||
* and event-driven integration in the Suaveplan platform.
|
||||
*/
|
||||
export const DomainEvent = z.object({
|
||||
/** Unique identifier for this specific event instance. */
|
||||
id: z.string().min(1),
|
||||
/** Fully-qualified event type name (e.g. "user.registered", "order.placed"). */
|
||||
type: z.string().min(1),
|
||||
/** Identifier of the aggregate that this event belongs to. */
|
||||
aggregateId: z.string().min(1),
|
||||
/** Type name of the aggregate (e.g. "User", "Order"). */
|
||||
aggregateType: z.string().min(1),
|
||||
/** Domain-specific payload; schema is defined by the aggregate's event type. */
|
||||
payload: z.unknown(),
|
||||
/** ISO 8601 timestamp of when this event occurred in the domain. */
|
||||
occurredAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
export type DomainEvent = z.infer<typeof DomainEvent>;
|
||||
69
packages/api-stack/api/api-schemas/src/events/envelope.md
Normal file
69
packages/api-stack/api/api-schemas/src/events/envelope.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# envelope
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `EventEnvelope` Zod schema — the transport wrapper that carries a domain event across message brokers, event store write operations, and event projector ingestion pipelines. The envelope separates the domain event (what happened) from the routing metadata (why it happened and who caused it) and adds a sequence number for ordering guarantees and gap detection during replay.
|
||||
|
||||
## Features
|
||||
|
||||
- `EventEnvelope` schema — composes `DomainEvent`, `EventMetadata`, and a `sequence` integer
|
||||
- `sequence` validated as a non-negative integer — zero is a valid sequence (first event in a store)
|
||||
- Separation of domain event, metadata, and sequence concerns into distinct fields
|
||||
- Composable from existing schemas — no duplication of `DomainEvent` or `EventMetadata` fields
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EventEnvelope } from "@suaveplan/api-schemas/events";
|
||||
|
||||
const envelope = EventEnvelope.parse({
|
||||
event: {
|
||||
id: "evt_1",
|
||||
type: "user.registered",
|
||||
aggregateId: "user_abc",
|
||||
aggregateType: "User",
|
||||
payload: { email: "alice@example.com" },
|
||||
occurredAt: "2024-06-01T12:00:00Z",
|
||||
},
|
||||
metadata: { correlationId: "corr_1", userId: "user_abc" },
|
||||
sequence: 42,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { EventEnvelope } from "@suaveplan/api-schemas/events";
|
||||
|
||||
// Deserialise a batch of envelopes from the event store
|
||||
function parseEnvelopes(rawRows: unknown[]): EventEnvelope[] {
|
||||
return rawRows.map((row) => EventEnvelope.parse(row));
|
||||
}
|
||||
|
||||
// Detect sequence gaps (missing events)
|
||||
function hasGap(envelopes: EventEnvelope[]): boolean {
|
||||
return envelopes.some(
|
||||
(env, i) => i > 0 && env.sequence !== (envelopes[i - 1]?.sequence ?? -1) + 1
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EventEnvelope`
|
||||
|
||||
Zod object schema — event transport wrapper.
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|-----------------|-------------------------------------------------------|
|
||||
| `event` | `DomainEvent` | The domain event being transported |
|
||||
| `metadata` | `EventMetadata` | Correlation, causation, and actor context |
|
||||
| `sequence` | `number` (int ≥ 0)| Store/broker sequence number for ordering |
|
||||
|
||||
**Type export:** `type EventEnvelope = z.infer<typeof EventEnvelope>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `sequence` field is assigned by the event store or message broker at write time — not by the aggregate or command handler. Consumers use `sequence` for two purposes: (1) ordered replay during projection rebuilds, and (2) gap detection when a consumer processes events from a partitioned broker and needs to confirm no events were missed. Using `z.number().int().min(0)` allows sequence 0 as the first valid sequence number, which is conventional in zero-based event stores.
|
||||
106
packages/api-stack/api/api-schemas/src/events/envelope.test.ts
Normal file
106
packages/api-stack/api/api-schemas/src/events/envelope.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EventEnvelope } from "./envelope.js";
|
||||
|
||||
const validEvent = {
|
||||
id: "evt_1",
|
||||
type: "user.registered",
|
||||
aggregateId: "user_abc",
|
||||
aggregateType: "User",
|
||||
payload: { email: "alice@example.com" },
|
||||
occurredAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
|
||||
const validMeta = { correlationId: "corr_1" };
|
||||
|
||||
describe("EventEnvelope", () => {
|
||||
it("parses valid event envelope", { timeout: 5000 }, () => {
|
||||
const result = EventEnvelope.parse({
|
||||
event: validEvent,
|
||||
metadata: validMeta,
|
||||
sequence: 42,
|
||||
});
|
||||
expect(result.sequence).toBe(42);
|
||||
expect(result.event.type).toBe("user.registered");
|
||||
expect(result.metadata.correlationId).toBe("corr_1");
|
||||
});
|
||||
|
||||
it("accepts sequence 0 (first event)", { timeout: 5000 }, () => {
|
||||
const result = EventEnvelope.parse({
|
||||
event: validEvent,
|
||||
metadata: validMeta,
|
||||
sequence: 0,
|
||||
});
|
||||
expect(result.sequence).toBe(0);
|
||||
});
|
||||
|
||||
it("parses envelope with full metadata", { timeout: 5000 }, () => {
|
||||
const result = EventEnvelope.parse({
|
||||
event: validEvent,
|
||||
metadata: {
|
||||
correlationId: "c",
|
||||
causationId: "cmd_1",
|
||||
userId: "u",
|
||||
tenantId: "t",
|
||||
},
|
||||
sequence: 1,
|
||||
});
|
||||
expect(result.metadata.tenantId).toBe("t");
|
||||
});
|
||||
|
||||
it("rejects negative sequence", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
EventEnvelope.parse({
|
||||
event: validEvent,
|
||||
metadata: validMeta,
|
||||
sequence: -1,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer sequence", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
EventEnvelope.parse({
|
||||
event: validEvent,
|
||||
metadata: validMeta,
|
||||
sequence: 1.5,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing event field", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
EventEnvelope.parse({ metadata: validMeta, sequence: 1 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing metadata field", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
EventEnvelope.parse({ event: validEvent, sequence: 1 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing sequence field", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
EventEnvelope.parse({ event: validEvent, metadata: validMeta })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid event payload (missing required fields)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
EventEnvelope.parse({
|
||||
event: {
|
||||
id: "",
|
||||
type: "t",
|
||||
aggregateId: "a",
|
||||
aggregateType: "A",
|
||||
payload: {},
|
||||
occurredAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
metadata: validMeta,
|
||||
sequence: 0,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
22
packages/api-stack/api/api-schemas/src/events/envelope.ts
Normal file
22
packages/api-stack/api/api-schemas/src/events/envelope.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import z from "zod";
|
||||
import { DomainEvent } from "./domain-event.js";
|
||||
import { EventMetadata } from "./metadata.js";
|
||||
|
||||
/**
|
||||
* Schema for an event envelope — the transport wrapper that carries a domain event
|
||||
* along with its routing metadata and a monotonically increasing sequence number.
|
||||
* Event envelopes are the unit of transmission in message brokers and event stores.
|
||||
*/
|
||||
export const EventEnvelope = z.object({
|
||||
/** The domain event being transported. */
|
||||
event: DomainEvent,
|
||||
/** Routing, correlation, and actor metadata for this event. */
|
||||
metadata: EventMetadata,
|
||||
/**
|
||||
* Monotonically increasing sequence number assigned by the event store or broker.
|
||||
* Used for ordering guarantees and gap detection.
|
||||
*/
|
||||
sequence: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export type EventEnvelope = z.infer<typeof EventEnvelope>;
|
||||
3
packages/api-stack/api/api-schemas/src/events/index.ts
Normal file
3
packages/api-stack/api/api-schemas/src/events/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { DomainEvent } from "./domain-event.js";
|
||||
export { EventEnvelope } from "./envelope.js";
|
||||
export { EventMetadata } from "./metadata.js";
|
||||
64
packages/api-stack/api/api-schemas/src/events/metadata.md
Normal file
64
packages/api-stack/api/api-schemas/src/events/metadata.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# metadata
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `EventMetadata` Zod schema for the distributed tracing and actor context attached to every domain event in the Suaveplan event-sourcing infrastructure. Metadata fields enable correlation of all events produced by a single user action, causation tracing between commands and their resulting events, and multi-tenant scoping of event streams — all without embedding these concerns in domain event payloads.
|
||||
|
||||
## Features
|
||||
|
||||
- `EventMetadata` schema — `{correlationId, causationId?, userId?, tenantId?}` with strict non-empty validation on all present string fields
|
||||
- `correlationId` required — enables log correlation and distributed trace aggregation across service boundaries
|
||||
- `causationId` optional — links this event to the specific command or event that caused it (event sourcing causal chain)
|
||||
- `userId` optional — identifies the human or service account that triggered the originating action
|
||||
- `tenantId` optional — scopes the event to a tenant in multi-tenant deployments
|
||||
- Full TypeScript inference via `z.infer<>` — no manual interface duplication
|
||||
- Zod v4 compatible
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EventMetadata } from "@suaveplan/api-schemas/events";
|
||||
|
||||
const meta = EventMetadata.parse({
|
||||
correlationId: "corr_01J8ZGCZ7ECTFAKEUID123456",
|
||||
userId: "user_abc",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { EventMetadata } from "@suaveplan/api-schemas/events";
|
||||
|
||||
// Build metadata for an event triggered by a command
|
||||
function buildEventMetadata(
|
||||
command: { correlationId: string; id: string },
|
||||
ctx: { userId?: string; tenantId?: string }
|
||||
): EventMetadata {
|
||||
return EventMetadata.parse({
|
||||
correlationId: command.correlationId,
|
||||
causationId: command.id,
|
||||
userId: ctx.userId,
|
||||
tenantId: ctx.tenantId,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EventMetadata`
|
||||
|
||||
Zod object schema — event tracing and actor context.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------------|--------------------|----------------------------------------------------------|
|
||||
| `correlationId` | `string` (min 1) | Ties all events from one user action together |
|
||||
| `causationId` | `string` (optional)| ID of the command/event that caused this event |
|
||||
| `userId` | `string` (optional)| Actor who triggered the originating action |
|
||||
| `tenantId` | `string` (optional)| Tenant scope for multi-tenant deployments |
|
||||
|
||||
**Type export:** `type EventMetadata = z.infer<typeof EventMetadata>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`correlationId` is the only required field because it is the primary key for grouping related events in log aggregation and tracing systems. All other fields are optional because not every event originates from an identifiable user action (e.g. scheduled jobs, system-initiated events) or requires tenant scoping. Optional fields that are present are validated as non-empty strings to prevent accidentally storing blank identifiers that would pollute trace data.
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EventMetadata } from "./metadata.js";
|
||||
|
||||
describe("EventMetadata", () => {
|
||||
it("parses minimal metadata (correlationId only)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = EventMetadata.parse({ correlationId: "corr_123" });
|
||||
expect(result.correlationId).toBe("corr_123");
|
||||
expect(result.causationId).toBeUndefined();
|
||||
expect(result.userId).toBeUndefined();
|
||||
expect(result.tenantId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses full metadata with all fields", { timeout: 5000 }, () => {
|
||||
const result = EventMetadata.parse({
|
||||
correlationId: "corr_1",
|
||||
causationId: "cmd_2",
|
||||
userId: "user_3",
|
||||
tenantId: "tenant_4",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
correlationId: "corr_1",
|
||||
causationId: "cmd_2",
|
||||
userId: "user_3",
|
||||
tenantId: "tenant_4",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses metadata with only correlationId and userId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = EventMetadata.parse({ correlationId: "c", userId: "u" });
|
||||
expect(result.userId).toBe("u");
|
||||
});
|
||||
|
||||
it("rejects missing correlationId", { timeout: 5000 }, () => {
|
||||
expect(() => EventMetadata.parse({ userId: "user_1" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty correlationId", { timeout: 5000 }, () => {
|
||||
expect(() => EventMetadata.parse({ correlationId: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty causationId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
EventMetadata.parse({ correlationId: "c", causationId: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty userId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
EventMetadata.parse({ correlationId: "c", userId: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty tenantId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
EventMetadata.parse({ correlationId: "c", tenantId: "" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
18
packages/api-stack/api/api-schemas/src/events/metadata.ts
Normal file
18
packages/api-stack/api/api-schemas/src/events/metadata.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for the metadata attached to a domain event or event envelope.
|
||||
* Carries distributed tracing identifiers and optional actor/tenant context.
|
||||
*/
|
||||
export const EventMetadata = z.object({
|
||||
/** Unique identifier tying all events produced by a single user-initiated action. */
|
||||
correlationId: z.string().min(1),
|
||||
/** Identifier of the command or event that directly caused this event (optional). */
|
||||
causationId: z.string().min(1).optional(),
|
||||
/** Identifier of the user who triggered the originating action (optional). */
|
||||
userId: z.string().min(1).optional(),
|
||||
/** Tenant scope for multi-tenant deployments (optional). */
|
||||
tenantId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type EventMetadata = z.infer<typeof EventMetadata>;
|
||||
1
packages/api-stack/api/api-schemas/src/helpers/index.ts
Normal file
1
packages/api-stack/api/api-schemas/src/helpers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { toSuaveplanError } from "./to-suaveplan-error.js";
|
||||
@@ -0,0 +1,74 @@
|
||||
# to-suaveplan-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a `toSuaveplanError` helper function that converts a Zod `ZodError` into a plain `ErrorEnvelope`-shaped object with per-field `FieldError` details. This helper bridges the Zod validation layer and the Suaveplan API error response format without introducing a runtime dependency on `@suaveplan/error`, keeping `@suaveplan/api-schemas` a true leaf package that can be imported in any environment.
|
||||
|
||||
## Features
|
||||
|
||||
- Converts `ZodError.issues` into an array of `FieldError` objects with dot-notation field paths
|
||||
- Aggregates all validation failures into a single `ErrorEnvelope` with `details: FieldError[]`
|
||||
- Customisable `code` and top-level `message` overrides via optional parameters
|
||||
- Issues with empty `path` (root-level failures) map to the special `"_root"` field name
|
||||
- Zero dependencies on `@suaveplan/error` — operates entirely within the schema layer
|
||||
- Full TypeScript inference — return type is `ErrorEnvelope & { details: FieldError[] }`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { toSuaveplanError } from "@suaveplan/api-schemas/helpers";
|
||||
|
||||
const schema = z.object({ email: z.string().email(), age: z.number().min(18) });
|
||||
const result = schema.safeParse({ email: "bad", age: 5 });
|
||||
if (!result.success) {
|
||||
const envelope = toSuaveplanError(result.error);
|
||||
console.log(envelope.code); // "VALIDATION_FAILED"
|
||||
console.log(envelope.details); // [{ field: "email", ... }, { field: "age", ... }]
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { toSuaveplanError } from "@suaveplan/api-schemas/helpers";
|
||||
|
||||
// Use a custom error code and message for a specific endpoint
|
||||
const LoginInput = z.object({ email: z.string().email(), password: z.string().min(1) });
|
||||
|
||||
function validateLoginBody(body: unknown) {
|
||||
const result = LoginInput.safeParse(body);
|
||||
if (!result.success) {
|
||||
throw toSuaveplanError(
|
||||
result.error,
|
||||
"LOGIN_VALIDATION_FAILED",
|
||||
"Login credentials failed validation."
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `toSuaveplanError(error, code?, message?)`
|
||||
|
||||
Converts a Zod `ZodError` to an `ErrorEnvelope & { details: FieldError[] }`.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------------|--------------------------------|----------------------------------------------------|
|
||||
| `error` | `ZodError` | — | The Zod validation error to convert |
|
||||
| `code` | `string` | `"VALIDATION_FAILED"` | Stable machine-readable error code |
|
||||
| `message` | `string` | `"Request validation failed."` | Human-readable top-level message |
|
||||
|
||||
**Returns:** `ErrorEnvelope & { details: FieldError[] }` — a plain object (not a class instance).
|
||||
|
||||
**Throws:** Nothing — all ZodError issues are converted to FieldError entries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Field paths from `ZodError.issues` are arrays of strings and numbers (for array indices). These are joined with `"."` to produce dot-notation paths (e.g. `"user.address.0.street"`), which aligns with the `FilterInput.field` convention and common form-library path formats. Issues with an empty `path` array represent root-level schema failures (e.g. a union discriminant mismatch) and are mapped to the sentinel field name `"_root"` to remain a valid non-empty string as required by the `FieldError` schema.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { toSuaveplanError } from "./to-suaveplan-error.js";
|
||||
|
||||
function getZodError(schema: z.ZodTypeAny, input: unknown): z.ZodError {
|
||||
const result = schema.safeParse(input);
|
||||
if (result.success) throw new Error("Expected schema to fail");
|
||||
return result.error;
|
||||
}
|
||||
|
||||
describe("toSuaveplanError", () => {
|
||||
it("uses default code and message", { timeout: 5000 }, () => {
|
||||
const schema = z.object({ email: z.string().email() });
|
||||
const error = getZodError(schema, { email: "bad" });
|
||||
const envelope = toSuaveplanError(error);
|
||||
expect(envelope.code).toBe("VALIDATION_FAILED");
|
||||
expect(envelope.message).toBe("Request validation failed.");
|
||||
});
|
||||
|
||||
it("accepts custom code and message", { timeout: 5000 }, () => {
|
||||
const schema = z.object({ name: z.string().min(1) });
|
||||
const error = getZodError(schema, { name: "" });
|
||||
const envelope = toSuaveplanError(
|
||||
error,
|
||||
"CUSTOM_CODE",
|
||||
"Custom message."
|
||||
);
|
||||
expect(envelope.code).toBe("CUSTOM_CODE");
|
||||
expect(envelope.message).toBe("Custom message.");
|
||||
});
|
||||
|
||||
it("maps nested field paths to dot notation", { timeout: 5000 }, () => {
|
||||
const schema = z.object({
|
||||
user: z.object({ email: z.string().email() }),
|
||||
});
|
||||
const error = getZodError(schema, { user: { email: "bad" } });
|
||||
const envelope = toSuaveplanError(error);
|
||||
expect(envelope.details[0]?.field).toBe("user.email");
|
||||
});
|
||||
|
||||
it("maps array index paths to dot notation", { timeout: 5000 }, () => {
|
||||
const schema = z.object({ tags: z.array(z.string().min(1)) });
|
||||
const error = getZodError(schema, { tags: ["valid", ""] });
|
||||
const envelope = toSuaveplanError(error);
|
||||
expect(envelope.details[0]?.field).toBe("tags.1");
|
||||
});
|
||||
|
||||
it("maps root-level issues to _root field", { timeout: 5000 }, () => {
|
||||
const schema = z.string().min(1);
|
||||
const error = getZodError(schema, "");
|
||||
const envelope = toSuaveplanError(error);
|
||||
expect(envelope.details[0]?.field).toBe("_root");
|
||||
});
|
||||
|
||||
it("includes zod issue code in FieldError.code", { timeout: 5000 }, () => {
|
||||
const schema = z.object({ count: z.number() });
|
||||
const error = getZodError(schema, { count: "not-a-number" });
|
||||
const envelope = toSuaveplanError(error);
|
||||
expect(envelope.details[0]?.code).toBeDefined();
|
||||
});
|
||||
|
||||
it("collects multiple field errors", { timeout: 5000 }, () => {
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
age: z.number().min(18),
|
||||
});
|
||||
const error = getZodError(schema, { email: "bad", age: 5 });
|
||||
const envelope = toSuaveplanError(error);
|
||||
expect(envelope.details.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("returns plain object (not a class instance)", { timeout: 5000 }, () => {
|
||||
const schema = z.object({ x: z.string() });
|
||||
const error = getZodError(schema, { x: 1 });
|
||||
const envelope = toSuaveplanError(error);
|
||||
expect(typeof envelope).toBe("object");
|
||||
expect(envelope).not.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("includes error message in each FieldError", { timeout: 5000 }, () => {
|
||||
const schema = z.object({ email: z.string().email() });
|
||||
const error = getZodError(schema, { email: "bad" });
|
||||
const envelope = toSuaveplanError(error);
|
||||
expect(typeof envelope.details[0]?.message).toBe("string");
|
||||
expect(envelope.details[0]?.message.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ZodError } from "zod";
|
||||
import type { ErrorEnvelope, FieldError } from "../common/error-envelope.js";
|
||||
|
||||
/**
|
||||
* Converts a Zod `ZodError` into a plain `ErrorEnvelope`-shaped object.
|
||||
*
|
||||
* Does not depend on `@suaveplan/error` — this helper operates entirely within
|
||||
* the schema layer so it can be consumed in any environment without additional
|
||||
* runtime dependencies.
|
||||
*
|
||||
* @param error - The `ZodError` to convert.
|
||||
* @param code - Optional stable error code. Defaults to `"VALIDATION_FAILED"`.
|
||||
* @param message - Optional override for the top-level message. Defaults to a
|
||||
* generic validation failure message.
|
||||
* @returns A plain `ErrorEnvelope` object with field-level `details`.
|
||||
*/
|
||||
export function toSuaveplanError(
|
||||
error: ZodError,
|
||||
code = "VALIDATION_FAILED",
|
||||
message = "Request validation failed."
|
||||
): ErrorEnvelope & { details: FieldError[] } {
|
||||
const details: FieldError[] = error.issues.map((issue) => {
|
||||
const field =
|
||||
issue.path.length > 0 ? issue.path.map(String).join(".") : "_root";
|
||||
return {
|
||||
field,
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
};
|
||||
});
|
||||
|
||||
return { code, message, details };
|
||||
}
|
||||
9
packages/api-stack/api/api-schemas/src/index.ts
Normal file
9
packages/api-stack/api/api-schemas/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Re-export z from zod for convenience
|
||||
import z from "zod";
|
||||
|
||||
export * from "./admin/index.js";
|
||||
export * from "./auth/index.js";
|
||||
export * from "./common/index.js";
|
||||
export * from "./events/index.js";
|
||||
export * from "./helpers/index.js";
|
||||
export { z };
|
||||
32
packages/api-stack/api/api-schemas/tests/dep-graph.test.ts
Normal file
32
packages/api-stack/api/api-schemas/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("no @suaveplan/* runtime deps (leaf package)", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual([]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual(["@opentelemetry/api", "tslib", "zod"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
20
packages/api-stack/api/api-schemas/tests/tree-shake.test.ts
Normal file
20
packages/api-stack/api/api-schemas/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 7 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-schemas/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-schemas/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
20
packages/api-stack/api/api-schemas/vite.config.ts
Normal file
20
packages/api-stack/api/api-schemas/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-schemas",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: ["tslib", "zod", "@opentelemetry/api", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/api-stack/api/api-schemas/vitest.config.ts
Normal file
28
packages/api-stack/api/api-schemas/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user