Add REST API plugin protocol schemas for Phase 2 implementation#551
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- Created plugin-rest-api.zod.ts with complete REST API plugin protocol - Implemented route registration schemas for Discovery, Metadata, Data CRUD, Batch, and Permission - Added request validation middleware configuration schema - Added response envelope configuration schema - Added error handling configuration schema - Added OpenAPI generation configuration schema - Created comprehensive test suite with 33 passing tests - All schemas build and generate JSON schemas successfully Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…nd clarify getMetaItemCached Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…ural types for improved item retrieval
…ies and lifecycle management - Introduced versioning strategies including urlPath, header, queryParam, and dateBased. - Defined version lifecycle states: preview, current, supported, deprecated, and retired. - Implemented VersionDefinitionSchema for detailed version metadata including release, deprecation, and migration guide. - Created VersioningConfigSchema for API version management configuration. - Added VersionNegotiationResponseSchema for handling version negotiation responses. - Established default versioning configuration for ObjectStack API. - Added unit tests for versioning schemas and negotiation logic to ensure correctness. - Expanded REST API routes to include view management, workflow, realtime, notifications, AI, i18n, analytics, hub management, and automation.
There was a problem hiding this comment.
Pull request overview
Adds protocol-layer Zod schemas (and generated JSON Schemas + docs/tests) to standardize REST API plugin route registration, middleware configuration, and OpenAPI generation within @objectstack/spec, plus introduces an API versioning protocol and a small ObjectQL metadata lookup tweak.
Changes:
- Add
plugin-rest-apiprotocol schemas with default route registrations and a Vitest suite. - Add
api/versioningschemas + tests, and export new API modules frompackages/spec/src/api/index.ts. - Update router mounts and adjust ObjectQL protocol metadata lookups (singular/plural fallback).
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/spec/src/api/versioning.zod.ts | New API version negotiation + lifecycle schemas and defaults. |
| packages/spec/src/api/versioning.test.ts | Tests covering versioning enums/schemas/defaults. |
| packages/spec/src/api/router.zod.ts | Adds additional protocol mount defaults (ui/workflow/realtime/notifications/ai/i18n/packages). |
| packages/spec/src/api/plugin-rest-api.zod.ts | Core REST API plugin protocol schemas + many default route registrations. |
| packages/spec/src/api/plugin-rest-api.test.ts | Tests for REST API plugin schemas and default registrations. |
| packages/spec/src/api/index.ts | Re-exports versioning + REST API plugin protocol. |
| packages/spec/json-schema/api/*.json | Generated JSON Schemas for IDE/tooling support of new REST API plugin types. |
| packages/spec/REST_API_PLUGIN.md | Implementation guide for the REST API plugin protocol. |
| packages/objectql/src/protocol.ts | Adds heuristic singular/plural fallback for registry metadata lookups. |
| * Service name that handles these routes | ||
| */ | ||
| service: z.string().describe('Core service name (metadata, data, auth, etc.)'), | ||
|
|
There was a problem hiding this comment.
RestApiRouteRegistrationSchema.service is currently z.string(), which allows typos and invalid service names. Elsewhere the spec has a canonical CoreServiceName enum used by DispatcherRouteSchema.service (packages/spec/src/api/dispatcher.zod.ts). Consider switching this field to CoreServiceName (or at least re-export/reuse it) so route registrations can be validated against the actual kernel service registry.
| export const VersionDefinitionSchema = z.object({ | ||
| /** Version identifier (e.g., "v1", "v2beta1", "2025-01-01") */ | ||
| version: z.string().describe('Version identifier (e.g., "v1", "v2beta1", "2025-01-01")'), | ||
|
|
||
| /** Current lifecycle status */ | ||
| status: VersionStatus.describe('Lifecycle status of this version'), | ||
|
|
||
| /** Date this version was released (ISO 8601 date) */ | ||
| releasedAt: z.string().describe('Release date (ISO 8601, e.g., "2025-01-15")'), | ||
|
|
||
| /** Date this version was deprecated (ISO 8601 date) */ | ||
| deprecatedAt: z.string().optional() | ||
| .describe('Deprecation date (ISO 8601). Only set for deprecated/retired versions'), | ||
|
|
||
| /** Date this version will be retired (ISO 8601 date) */ | ||
| sunsetAt: z.string().optional() | ||
| .describe('Sunset date (ISO 8601). After this date, the version returns 410 Gone'), | ||
|
|
||
| /** URL to migration guide for moving to a newer version */ | ||
| migrationGuide: z.string().url().optional() | ||
| .describe('URL to migration guide for upgrading from this version'), | ||
|
|
||
| /** Human-readable description of this version */ | ||
| description: z.string().optional() | ||
| .describe('Human-readable description or release notes summary'), | ||
|
|
||
| /** Breaking changes introduced in or since this version */ | ||
| breakingChanges: z.array(z.string()).optional() | ||
| .describe('List of breaking changes (for preview/new versions)'), | ||
| }); | ||
|
|
There was a problem hiding this comment.
VersionDefinitionSchema.releasedAt / deprecatedAt / sunsetAt are described as ISO 8601 dates, but they’re currently unconstrained z.string(). The repo commonly validates timestamps with z.string().datetime(); consider at least enforcing a date-only format (e.g. YYYY-MM-DD) and/or using superRefine to enforce lifecycle rules (e.g. deprecated versions must have deprecatedAt, sunsetAt after deprecatedAt).
| export const VersionDefinitionSchema = z.object({ | |
| /** Version identifier (e.g., "v1", "v2beta1", "2025-01-01") */ | |
| version: z.string().describe('Version identifier (e.g., "v1", "v2beta1", "2025-01-01")'), | |
| /** Current lifecycle status */ | |
| status: VersionStatus.describe('Lifecycle status of this version'), | |
| /** Date this version was released (ISO 8601 date) */ | |
| releasedAt: z.string().describe('Release date (ISO 8601, e.g., "2025-01-15")'), | |
| /** Date this version was deprecated (ISO 8601 date) */ | |
| deprecatedAt: z.string().optional() | |
| .describe('Deprecation date (ISO 8601). Only set for deprecated/retired versions'), | |
| /** Date this version will be retired (ISO 8601 date) */ | |
| sunsetAt: z.string().optional() | |
| .describe('Sunset date (ISO 8601). After this date, the version returns 410 Gone'), | |
| /** URL to migration guide for moving to a newer version */ | |
| migrationGuide: z.string().url().optional() | |
| .describe('URL to migration guide for upgrading from this version'), | |
| /** Human-readable description of this version */ | |
| description: z.string().optional() | |
| .describe('Human-readable description or release notes summary'), | |
| /** Breaking changes introduced in or since this version */ | |
| breakingChanges: z.array(z.string()).optional() | |
| .describe('List of breaking changes (for preview/new versions)'), | |
| }); | |
| const IsoDateRegex = /^\d{4}-\d{2}-\d{2}$/; | |
| /** | |
| * ISO 8601 calendar date string (YYYY-MM-DD). | |
| */ | |
| const IsoDateString = z | |
| .string() | |
| .regex(IsoDateRegex, 'Invalid ISO 8601 date (expected YYYY-MM-DD)'); | |
| export const VersionDefinitionSchema = z | |
| .object({ | |
| /** Version identifier (e.g., "v1", "v2beta1", "2025-01-01") */ | |
| version: z | |
| .string() | |
| .describe('Version identifier (e.g., "v1", "v2beta1", "2025-01-01")'), | |
| /** Current lifecycle status */ | |
| status: VersionStatus.describe('Lifecycle status of this version'), | |
| /** Date this version was released (ISO 8601 date, YYYY-MM-DD) */ | |
| releasedAt: IsoDateString.describe( | |
| 'Release date (ISO 8601 calendar date, e.g., "2025-01-15")', | |
| ), | |
| /** Date this version was deprecated (ISO 8601 date, YYYY-MM-DD) */ | |
| deprecatedAt: IsoDateString.optional().describe( | |
| 'Deprecation date (ISO 8601 calendar date). Only set for deprecated/retired versions', | |
| ), | |
| /** Date this version will be retired (ISO 8601 date, YYYY-MM-DD) */ | |
| sunsetAt: IsoDateString.optional().describe( | |
| 'Sunset date (ISO 8601 calendar date). After this date, the version returns 410 Gone', | |
| ), | |
| /** URL to migration guide for moving to a newer version */ | |
| migrationGuide: z | |
| .string() | |
| .url() | |
| .optional() | |
| .describe('URL to migration guide for upgrading from this version'), | |
| /** Human-readable description of this version */ | |
| description: z | |
| .string() | |
| .optional() | |
| .describe('Human-readable description or release notes summary'), | |
| /** Breaking changes introduced in or since this version */ | |
| breakingChanges: z | |
| .array(z.string()) | |
| .optional() | |
| .describe('List of breaking changes (for preview/new versions)'), | |
| }) | |
| .superRefine((value, ctx) => { | |
| const { status, releasedAt, deprecatedAt, sunsetAt } = value; | |
| // Lifecycle presence rules | |
| if ((status === 'deprecated' || status === 'retired') && !deprecatedAt) { | |
| ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| path: ['deprecatedAt'], | |
| message: 'deprecatedAt is required when status is "deprecated" or "retired".', | |
| }); | |
| } | |
| if (status === 'retired' && !sunsetAt) { | |
| ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| path: ['sunsetAt'], | |
| message: 'sunsetAt is required when status is "retired".', | |
| }); | |
| } | |
| // Ordering rules: releasedAt <= deprecatedAt <= sunsetAt (where present) | |
| const parse = (s: string | undefined) => | |
| s ? new Date(s + 'T00:00:00.000Z').getTime() : undefined; | |
| const releasedAtTime = parse(releasedAt); | |
| const deprecatedAtTime = parse(deprecatedAt); | |
| const sunsetAtTime = parse(sunsetAt); | |
| if (releasedAtTime !== undefined && deprecatedAtTime !== undefined) { | |
| if (deprecatedAtTime < releasedAtTime) { | |
| ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| path: ['deprecatedAt'], | |
| message: 'deprecatedAt must be on or after releasedAt.', | |
| }); | |
| } | |
| } | |
| if (releasedAtTime !== undefined && sunsetAtTime !== undefined) { | |
| if (sunsetAtTime < releasedAtTime) { | |
| ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| path: ['sunsetAt'], | |
| message: 'sunsetAt must be on or after releasedAt.', | |
| }); | |
| } | |
| } | |
| if (deprecatedAtTime !== undefined && sunsetAtTime !== undefined) { | |
| if (sunsetAtTime < deprecatedAtTime) { | |
| ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| path: ['sunsetAt'], | |
| message: 'sunsetAt must be on or after deprecatedAt.', | |
| }); | |
| } | |
| } | |
| }); |
| /** Current (recommended) API version */ | ||
| current: z.string().describe('The current/recommended API version identifier'), | ||
|
|
||
| /** Default version when none specified by client */ | ||
| default: z.string().describe('Fallback version when client does not specify one'), | ||
|
|
||
| /** All available API versions */ | ||
| versions: z.array(VersionDefinitionSchema) | ||
| .min(1) | ||
| .describe('All available API versions with lifecycle metadata'), |
There was a problem hiding this comment.
VersioningConfigSchema doesn’t validate internal consistency (e.g. that current and default exist in versions, or that versions[].version values are unique). Since this schema is meant for negotiation/validation, adding a superRefine would prevent configs that can’t be resolved at runtime.
| async getMetaItems(request: { type: string; packageId?: string }) { | ||
| let items = SchemaRegistry.listItems(request.type, request.packageId); | ||
| // Normalize singular/plural: REST uses singular ('app') but registry may store as plural ('apps') | ||
| if (items.length === 0) { | ||
| const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's'; | ||
| items = SchemaRegistry.listItems(alt, request.packageId); | ||
| } |
There was a problem hiding this comment.
The singular/plural fallback for type is too naive: endsWith('s') ? slice(0, -1) : + 's' will generate incorrect alternates for many valid registry keys (e.g. policies -> policie, analytics -> analytic) and may mask real “type not found” errors. Consider restricting this normalization to known pairs (e.g. app<->apps) or using an explicit alias map rather than heuristic string slicing.
| }, | ||
| ], | ||
| middleware: [ | ||
| { name: 'auth', type: 'authentication', enabled: true, order: 10 }, |
There was a problem hiding this comment.
DEFAULT_PERMISSION_ROUTES defines a POST endpoint (/check) with requestSchema: 'CheckPermissionRequestSchema', but the route group middleware omits the validation middleware. If request validation is meant to be standardized via middleware, add validation here (and/or clarify that validation is applied globally rather than per registration).
| { name: 'auth', type: 'authentication', enabled: true, order: 10 }, | |
| { name: 'auth', type: 'authentication', enabled: true, order: 10 }, | |
| { name: 'request_validation', type: 'validation', enabled: true, order: 50 }, |
| /** | ||
| * URL path pattern (supports parameters like :id) | ||
| */ | ||
| path: z.string().describe('URL path pattern (e.g., /api/v1/data/:object/:id)'), | ||
|
|
||
| /** |
There was a problem hiding this comment.
RestApiEndpointSchema.path is documented as a full URL path (e.g. /api/v1/data/:object/:id), but the defaults use it as relative to the registration prefix (e.g. prefix: '/api/v1/meta' with endpoint path: '/:type', and discovery uses path: ''). This mismatch will confuse OpenAPI generation / route joining. Clarify in the schema docs (and ideally enforce either absolute or relative paths consistently).
| * Route groups (13 total): | ||
| * 1. Discovery - API capabilities and routing info | ||
| * 2. Metadata - Object/field schema CRUD | ||
| * 3. Data CRUD - Record operations | ||
| * 4. Batch - Bulk operations | ||
| * 5. Permission - Authorization checks | ||
| * 6. Views - UI view CRUD | ||
| * 7. Workflow - State machine transitions | ||
| * 8. Realtime - WebSocket/SSE connections | ||
| * 9. Notification - Push notifications and preferences | ||
| * 10. AI - NLQ, chat, suggestions, insights | ||
| * 11. i18n - Locales and translations | ||
| * 12. Analytics - BI queries and metadata | ||
| * 13. Hub - Space and package management | ||
| * 14. Automation - Trigger flows and scripts | ||
| */ |
There was a problem hiding this comment.
The docstring for getDefaultRouteRegistrations() says “Route groups (13 total)” but the function returns 14 registrations and the list below is numbered 1–14. Update the comment to match the actual set to avoid misleading implementers.
| export const DEFAULT_HUB_ROUTES: RestApiRouteRegistration = { | ||
| prefix: '/api/v1/hub', | ||
| service: 'hub', | ||
| category: 'hub', | ||
| methods: [ |
There was a problem hiding this comment.
DEFAULT_HUB_ROUTES registers prefix /api/v1/hub, but the dispatcher’s default route table includes both /api/v1/hub and /api/v1/packages mapped to the hub service (packages/spec/src/api/dispatcher.zod.ts). If this schema is used for standardized registration/OpenAPI generation, the /api/v1/packages namespace will be uncovered. Consider adding a separate default registration for /api/v1/packages (or changing the prefix) to align with the dispatcher defaults.
| export function getDefaultRouteRegistrations(): RestApiRouteRegistration[] { | ||
| return [ | ||
| DEFAULT_DISCOVERY_ROUTES, | ||
| DEFAULT_METADATA_ROUTES, | ||
| DEFAULT_DATA_CRUD_ROUTES, | ||
| DEFAULT_BATCH_ROUTES, | ||
| DEFAULT_PERMISSION_ROUTES, | ||
| DEFAULT_VIEW_ROUTES, | ||
| DEFAULT_WORKFLOW_ROUTES, | ||
| DEFAULT_REALTIME_ROUTES, | ||
| DEFAULT_NOTIFICATION_ROUTES, | ||
| DEFAULT_AI_ROUTES, | ||
| DEFAULT_I18N_ROUTES, | ||
| DEFAULT_ANALYTICS_ROUTES, | ||
| DEFAULT_HUB_ROUTES, | ||
| DEFAULT_AUTOMATION_ROUTES, | ||
| ]; |
There was a problem hiding this comment.
This PR adds more than the Phase 2 “5 default registrations” described in the PR description (the implementation includes 14 default route groups, plus separate API versioning and ObjectQL protocol changes). Either update the PR description to reflect the expanded scope, or split non-Phase-2 additions into separate PRs to keep review/risk scoped.
| async getMetaItem(request: { type: string, name: string }) { | ||
| let item = SchemaRegistry.getItem(request.type, request.name); | ||
| // Normalize singular/plural | ||
| if (item === undefined) { | ||
| const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's'; | ||
| item = SchemaRegistry.getItem(alt, request.name); | ||
| } |
There was a problem hiding this comment.
Same issue as above in getMetaItem: the heuristic singular/plural fallback can produce invalid type names and hide actual lookup failures. Prefer a small explicit alias map (or only special-case app/apps) so registry lookups remain predictable.
Implements the protocol layer for REST API plugins, enabling standardized route registration, validation, response wrapping, error handling, and OpenAPI generation across all ObjectStack services.
Core Schemas
Route Registration (
RestApiRouteRegistrationSchema)Request Validation (
RequestValidationConfigSchema)strict(reject invalid),permissive(log only),strip(remove invalid fields)Response Envelope (
ResponseEnvelopeConfigSchema)BaseResponseSchemaformatError Handling (
ErrorHandlingConfigSchema)ApiErrorSchemaformatOpenAPI Generation (
OpenApiGenerationConfigSchema)Example Usage
Response format:
{ "success": true, "data": { "id": "123", "name": "example" }, "meta": { "timestamp": "2026-02-08T10:00:00Z", "requestId": "req_abc", "duration": 45 } }Files Added
src/api/plugin-rest-api.zod.ts- Protocol schemas (950 lines)src/api/plugin-rest-api.test.ts- Test suite (33 tests)REST_API_PLUGIN.md- Implementation guidePhase 3/4 plugins (UI, Workflow, Analytics, Realtime, AI) will extend this pattern.
Original prompt
Created from VS Code.
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.