Skip to content

Add REST API plugin protocol schemas for Phase 2 implementation#551

Merged
hotlong merged 7 commits into
mainfrom
copilot/implement-plugin-rest-api
Feb 8, 2026
Merged

Add REST API plugin protocol schemas for Phase 2 implementation#551
hotlong merged 7 commits into
mainfrom
copilot/implement-plugin-rest-api

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 8, 2026

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)

  • Groups endpoints by prefix with shared middleware stack
  • 5 default registrations: Discovery, Metadata, Data CRUD, Batch, Permission
  • Middleware execution order: Auth (10) → Validation (20) → Envelope (100) → Error (200)

Request Validation (RequestValidationConfigSchema)

  • Three modes: strict (reject invalid), permissive (log only), strip (remove invalid fields)
  • Validates body/query/params/headers against Zod schemas
  • Field-level error reporting with custom messages

Response Envelope (ResponseEnvelopeConfigSchema)

  • Auto-wraps responses in BaseResponseSchema format
  • Injects metadata: timestamp, requestId, duration, traceId
  • Skip-if-wrapped logic for idempotency

Error Handling (ErrorHandlingConfigSchema)

  • Standardizes errors to ApiErrorSchema format
  • Documentation URL generation by error code
  • Field redaction for sensitive data (password, ssn, etc.)

OpenAPI Generation (OpenApiGenerationConfigSchema)

  • Supports OpenAPI 3.0.x/3.1.0
  • Auto-generates schemas from Zod definitions
  • Multiple UI frameworks: Swagger UI, Redoc, RapiDoc, Elements

Example Usage

import { RestApiPluginConfig, DEFAULT_DATA_CRUD_ROUTES } from '@objectstack/spec';

const config: RestApiPluginConfig = {
  basePath: '/api',
  version: 'v1',
  routes: [DEFAULT_DATA_CRUD_ROUTES],
  validation: { mode: 'strict', validateBody: true },
  responseEnvelope: { includeMetadata: true },
  errorHandling: { includeDocumentation: true },
  openApi: { generateSchemas: true, uiFramework: 'swagger-ui' },
};

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 guide
  • 9 JSON schemas for IDE support

Phase 3/4 plugins (UI, Workflow, Analytics, Realtime, AI) will extend this pattern.

Original prompt

API Protocol 实现计划

Phase 2: 核心 REST API 插件(2-3 周)

  • 2.1 实现 plugin-rest-api:注册 Discovery、Metadata、Data CRUD、Batch、Permission 路由
  • 2.2 Request 校验中间件:使用 Zod Schema 自动验证请求体
  • 2.3 Response 封装:统一使用 BaseResponseSchema 信封格式
  • 2.4 错误处理:统一使用 ApiErrorSchema 格式
  • 2.5 OpenAPI 文档自动生成(基于 documentation.zod.ts

The user has attached the following file paths as relevant context:

  • .github/copilot-instructions.md

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.

@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-studio Error Error Feb 8, 2026 2:34pm
spec Ready Ready Preview, Comment Feb 8, 2026 2:34pm

Request Review

Copilot AI and others added 3 commits February 8, 2026 10:59
- 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>
Copilot AI changed the title [WIP] Implement core REST API plugin with routes and error handling Add REST API plugin protocol schemas for Phase 2 implementation Feb 8, 2026
Copilot AI requested a review from hotlong February 8, 2026 11:06
…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.
@hotlong hotlong marked this pull request as ready for review February 8, 2026 14:29
Copilot AI review requested due to automatic review settings February 8, 2026 14:29
@hotlong hotlong merged commit 015dabc into main Feb 8, 2026
11 of 14 checks passed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-api protocol schemas with default route registrations and a Vitest suite.
  • Add api/versioning schemas + tests, and export new API modules from packages/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.

Comment on lines +195 to +198
* Service name that handles these routes
*/
service: z.string().describe('Core service name (metadata, data, auth, etc.)'),

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +120
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)'),
});

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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.',
});
}
}
});

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +160
/** 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'),
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 61 to +67
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);
}
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
},
],
middleware: [
{ name: 'auth', type: 'authentication', enabled: true, order: 10 },
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
{ 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 },

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +121
/**
* URL path pattern (supports parameters like :id)
*/
path: z.string().describe('URL path pattern (e.g., /api/v1/data/:object/:id)'),

/**
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +1787 to +1802
* 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
*/
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1589 to +1593
export const DEFAULT_HUB_ROUTES: RestApiRouteRegistration = {
prefix: '/api/v1/hub',
service: 'hub',
category: 'hub',
methods: [
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1803 to +1819
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,
];
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 74 to +80
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);
}
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants