Skip to content

Commit e18377d

Browse files
committed
improvement(repo): zod schema contracts
1 parent 2e3de9a commit e18377d

1,114 files changed

Lines changed: 39516 additions & 21706 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/add-integration/SKILL.md

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -578,38 +578,67 @@ tools: {
578578

579579
#### 3. Create Internal API Route
580580

581-
Create `apps/sim/app/api/tools/{service}/{action}/route.ts`:
581+
Create `apps/sim/app/api/tools/{service}/{action}/route.ts`. Internal tool routes are HTTP boundaries and follow the same contract policy as public routes — define the request/response shape in `apps/sim/lib/api/contracts/{service}-tools.ts` (or an existing `internal-tools.ts` / `communication-tools.ts` aggregate) and validate with canonical helpers from `@/lib/api/server`. Never write a route-local Zod schema.
582582

583583
```typescript
584+
// apps/sim/lib/api/contracts/{service}-tools.ts
585+
import { z } from 'zod'
586+
import { defineRouteContract } from '@/lib/api/contracts'
587+
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
588+
589+
export const {service}UploadBodySchema = z.object({
590+
accessToken: z.string(),
591+
file: FileInputSchema.optional().nullable(),
592+
fileContent: z.string().optional().nullable(),
593+
// ... other params
594+
})
595+
596+
export const {service}UploadResponseSchema = z.object({
597+
success: z.boolean(),
598+
output: z.object({ id: z.string(), url: z.string() }).optional(),
599+
error: z.string().optional(),
600+
})
601+
602+
export const {service}UploadContract = defineRouteContract({
603+
method: 'POST',
604+
path: '/api/tools/{service}/upload',
605+
body: {service}UploadBodySchema,
606+
response: { mode: 'json', schema: {service}UploadResponseSchema },
607+
})
608+
609+
export type {Service}UploadBody = z.input<typeof {service}UploadBodySchema>
610+
export type {Service}UploadResponse = z.output<typeof {service}UploadResponseSchema>
611+
```
612+
613+
```typescript
614+
// apps/sim/app/api/tools/{service}/upload/route.ts
584615
import { createLogger } from '@sim/logger'
585616
import { NextResponse, type NextRequest } from 'next/server'
586-
import { z } from 'zod'
617+
import { {service}UploadContract } from '@/lib/api/contracts/{service}-tools'
618+
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
587619
import { checkInternalAuth } from '@/lib/auth/hybrid'
588620
import { generateRequestId } from '@/lib/core/utils/request'
589-
import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas'
621+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
622+
import { type RawFileInput } from '@/lib/uploads/utils/file-schemas'
590623
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
591624
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
592625

593626
const logger = createLogger('{Service}UploadAPI')
594627

595-
const RequestSchema = z.object({
596-
accessToken: z.string(),
597-
file: FileInputSchema.optional().nullable(),
598-
// Legacy field for backwards compatibility
599-
fileContent: z.string().optional().nullable(),
600-
// ... other params
601-
})
602-
603-
export async function POST(request: NextRequest) {
628+
export const POST = withRouteHandler(async (request: NextRequest) => {
604629
const requestId = generateRequestId()
605630

606631
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
607632
if (!authResult.success) {
608633
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
609634
}
610635

611-
const body = await request.json()
612-
const data = RequestSchema.parse(body)
636+
let data
637+
try {
638+
;({ body: data } = await parseRequest({service}UploadContract, request))
639+
} catch (error) {
640+
return validationErrorResponseFromError(error)
641+
}
613642

614643
let fileBuffer: Buffer
615644
let fileName: string
@@ -624,22 +653,20 @@ export async function POST(request: NextRequest) {
624653
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
625654
fileName = userFile.name
626655
} else if (data.fileContent) {
627-
// Legacy: base64 string (backwards compatibility)
628656
fileBuffer = Buffer.from(data.fileContent, 'base64')
629657
fileName = 'file'
630658
} else {
631659
return NextResponse.json({ success: false, error: 'File required' }, { status: 400 })
632660
}
633661

634-
// Now call external API with fileBuffer
635662
const response = await fetch('https://api.{service}.com/upload', {
636663
method: 'POST',
637664
headers: { Authorization: `Bearer ${data.accessToken}` },
638-
body: new Uint8Array(fileBuffer), // Convert Buffer for fetch
665+
body: new Uint8Array(fileBuffer),
639666
})
640667

641668
// ... handle response
642-
}
669+
})
643670
```
644671

645672
#### 4. Update Tool to Use Internal Route

.cursor/rules/sim-architecture.mdc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,14 @@ feature/
5454
- **Create `utils.ts` when** 2+ files need the same helper
5555
- **Check existing sources** before duplicating (`lib/` has many utilities)
5656
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
57+
58+
## API Contracts
59+
60+
Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/` (one file per resource family). Routes and clients consume the same contract — routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types. Domain validators that are not HTTP boundaries (tools, blocks, triggers, connectors, realtime handlers, internal helpers) may still use Zod directly; the contract rule is boundary-only.
61+
62+
- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` and exports both schemas and named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input<typeof createFolderBodySchema>`).
63+
- Shared identifier schemas live in `apps/sim/lib/api/contracts/primitives.ts`.
64+
- Routes validate via canonical helpers in `apps/sim/lib/api/server/validation.ts` (`parseRequest`, `validateJsonBody`, `validateSchema`, `validationErrorResponse`, `getValidationErrorMessage`, `isZodError`). Routes never `import { z } from 'zod'` and never use `instanceof z.ZodError`.
65+
- Clients call `requestJson(contract, ...)` from `apps/sim/lib/api/client/request.ts`; hooks import named type aliases from contracts, never `z.input/z.output`.
66+
- Routes under `apps/sim/app/api/v1/**` use `apps/sim/app/api/v1/middleware.ts` for shared auth, rate-limit, and workspace access. Compose contract validation inside that middleware.
67+
- `bun run check:api-validation` enforces this policy and must pass on PRs.

.cursor/rules/sim-queries.mdc

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,18 @@ Never use inline query keys — always use the factory.
3737
- Every `queryFn` must destructure and forward `signal` for request cancellation
3838
- Every query must have an explicit `staleTime`
3939
- Use `keepPreviousData` only on variable-key queries (where params change), never on static keys
40+
- Same-origin JSON calls must go through `requestJson(contract, ...)` from `@/lib/api/client/request` against the contract in `@/lib/api/contracts/**`
4041

4142
```typescript
42-
async function fetchEntities(workspaceId: string, signal?: AbortSignal) {
43-
const response = await fetch(`/api/entities?workspaceId=${workspaceId}`, { signal })
44-
if (!response.ok) throw new Error('Failed to fetch entities')
45-
return response.json()
43+
import { requestJson } from '@/lib/api/client/request'
44+
import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'
45+
46+
async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise<EntityList> {
47+
const data = await requestJson(listEntitiesContract, {
48+
query: { workspaceId },
49+
signal,
50+
})
51+
return data.entities
4652
}
4753

4854
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
@@ -51,7 +57,7 @@ export function useEntityList(workspaceId?: string, options?: { enabled?: boolea
5157
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
5258
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
5359
staleTime: 60 * 1000,
54-
placeholderData: keepPreviousData, // OK: workspaceId varies
60+
placeholderData: keepPreviousData,
5561
})
5662
}
5763
```
@@ -118,6 +124,12 @@ const handler = useCallback(() => {
118124
}, [data])
119125
```
120126

127+
## Boundary Types
128+
129+
- Hooks must import named type aliases from `@/lib/api/contracts/**` (e.g., `import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'`). Never write `z.input<...>` or `z.output<...>` in hooks.
130+
- Hooks must not `import { z } from 'zod'`. Boundary types come from contract aliases; non-boundary helpers can stay in plain TypeScript.
131+
- For non-contract endpoints (multipart uploads, binary downloads, streaming responses, signed-URL flows, OAuth redirects, external origins), it is OK to keep raw `fetch`. Mark each raw `fetch` with a TSDoc comment explaining which exception applies.
132+
121133
## Naming
122134

123135
- **Keys**: `entityKeys`

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ jobs:
106106
- name: Enforce monorepo boundaries
107107
run: bun run check:boundaries
108108

109+
- name: API contract boundary audit
110+
run: bun run check:api-validation:strict
111+
109112
- name: Verify realtime prune graph
110113
run: bun run check:realtime-prune
111114

AGENTS.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ You are a professional software engineer. All code must follow best practices: a
44

55
## Global Standards
66

7+
- **Linting / Audit**: `bun run check:api-validation` must pass on PRs. Do not introduce route-local boundary Zod schemas, direct route Zod imports, or ad-hoc client wire types — see "API Contracts" and "API Route Pattern" below
78
- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`
89
- **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments
910
- **Styling**: Never update global styles. Keep all styling local to components
@@ -115,6 +116,78 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps
115116

116117
Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational.
117118

119+
## API Contracts
120+
121+
Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` (one file per resource family — `folders.ts`, `chats.ts`, `knowledge.ts`, etc.). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.
122+
123+
- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` from `@/lib/api/contracts`
124+
- Contracts export named schemas (e.g., `createFolderBodySchema`) AND named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input<typeof createFolderBodySchema>`)
125+
- Clients (hooks, utilities, components) import the named type aliases from the contract file. They must never write `z.input<...>` / `z.output<...>` themselves
126+
- Shared identifier schemas live in `apps/sim/lib/api/contracts/primitives.ts` (e.g., `workspaceIdSchema`, `workflowIdSchema`). Reuse these instead of redefining string-based ID schemas
127+
- Audit script: `bun run check:api-validation` enforces boundary policy and prints ratchet metrics for route Zod imports, route-local schema constructors, route `ZodError` references, client hook Zod imports, and related counters. It must pass on PRs
128+
129+
Domain validators that are not HTTP boundaries — tools, blocks, triggers, connectors, realtime handlers, and internal helpers — may still use Zod directly. The contract rule is boundary-only.
130+
131+
## API Route Pattern
132+
133+
Routes never `import { z } from 'zod'` and never define route-local boundary schemas. They consume the contract from `@/lib/api/contracts/**` and validate with canonical helpers from `@/lib/api/server`:
134+
135+
- `parseRequest(contract, request, context)` — fully contract-bound routes; parses params, query, body, and headers in one call
136+
- `validateJsonBody(request, schema)` — when the body schema comes from a contract but you need to assemble query/headers manually
137+
- `validateSchema(schema, data)` — for ad-hoc validation against a contract schema or primitive
138+
- `validationErrorResponse(error)` and `getValidationErrorMessage(error, fallback)` — produce 400 responses from a `ZodError`
139+
- `validationErrorResponseFromError(error)` — when handling unknown caught errors that may or may not be a `ZodError`
140+
- `isZodError(error)` — type guard. Routes never use `instanceof z.ZodError`
141+
142+
### Fully contract-bound route (`parseRequest`)
143+
144+
```typescript
145+
import { createLogger } from '@sim/logger'
146+
import type { NextRequest } from 'next/server'
147+
import { NextResponse } from 'next/server'
148+
import { createFolderContract } from '@/lib/api/contracts/folders'
149+
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
150+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
151+
152+
const logger = createLogger('FoldersAPI')
153+
154+
export const POST = withRouteHandler(async (request: NextRequest) => {
155+
try {
156+
const { body } = await parseRequest(createFolderContract, request)
157+
logger.info('Creating folder', { workspaceId: body.workspaceId })
158+
return NextResponse.json({ ok: true })
159+
} catch (error) {
160+
return validationErrorResponseFromError(error)
161+
}
162+
})
163+
```
164+
165+
### Partial validation (`validateJsonBody`)
166+
167+
```typescript
168+
import type { NextRequest } from 'next/server'
169+
import { NextResponse } from 'next/server'
170+
import { updateFolderBodySchema } from '@/lib/api/contracts/folders'
171+
import { isZodError, validateJsonBody, validationErrorResponse } from '@/lib/api/server'
172+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
173+
174+
export const PATCH = withRouteHandler(async (
175+
request: NextRequest,
176+
{ params }: { params: Promise<{ id: string }> }
177+
) => {
178+
const { id } = await params
179+
try {
180+
const body = await validateJsonBody(request, updateFolderBodySchema)
181+
return NextResponse.json({ id, ...body })
182+
} catch (error) {
183+
if (isZodError(error)) return validationErrorResponse(error)
184+
throw error
185+
}
186+
})
187+
```
188+
189+
Routes under `apps/sim/app/api/v1/**` use the shared middleware in `apps/sim/app/api/v1/middleware.ts` for auth, rate-limit, and workspace access. Compose contract validation inside that middleware — never reimplement auth/rate-limit per-route.
190+
118191
## Hooks
119192

120193
```typescript
@@ -160,6 +233,38 @@ Use `devtools` middleware. Use `persist` only when data should survive reload wi
160233

161234
All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations.
162235

236+
### Client Boundary
237+
238+
Hooks consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`:
239+
240+
- Hooks import named type aliases from `@/lib/api/contracts/**`. Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code
241+
- `requestJson` parses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forward `signal` for cancellation
242+
- Documented exceptions for raw `fetch`: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. Mark each raw `fetch` with a TSDoc comment explaining which exception applies
243+
244+
```typescript
245+
import { keepPreviousData, useQuery } from '@tanstack/react-query'
246+
import { requestJson } from '@/lib/api/client/request'
247+
import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'
248+
249+
async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise<EntityList> {
250+
const data = await requestJson(listEntitiesContract, {
251+
query: { workspaceId },
252+
signal,
253+
})
254+
return data.entities
255+
}
256+
257+
export function useEntityList(workspaceId?: string) {
258+
return useQuery({
259+
queryKey: entityKeys.list(workspaceId),
260+
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
261+
enabled: Boolean(workspaceId),
262+
staleTime: 60 * 1000,
263+
placeholderData: keepPreviousData,
264+
})
265+
}
266+
```
267+
163268
### Query Key Factory
164269

165270
Every file must have a hierarchical key factory with an `all` root key and intermediate plural keys for prefix invalidation:

0 commit comments

Comments
 (0)