diff --git a/.plans/refactor/README.md b/.plans/refactor/README.md index 7d9a370..fbaffe8 100644 --- a/.plans/refactor/README.md +++ b/.plans/refactor/README.md @@ -41,6 +41,7 @@ Blocking: docs-sweep blocks llm-skills | [rate-limiter-lazy](queued/rate-limiter-lazy.md) | P1b+ | Lazy-import RateLimiter. Small. | | [cache-drivers](queued/cache-drivers.md) | P1c | Cache driver abstraction. Small. | | [test-helpers](queued/test-helpers.md) | P1i | Framework test helpers usable from `node:test` + vitest. ~½ d. | +| [yup-optional](queued/yup-optional.md) | P1k | Un-bundle yup: built-in schemas → `defineSchema` (zero-dep Standard Schema). yup leaves `dependencies`. Codegen unchanged. ~½ d. | | [codegen-incremental](queued/codegen-incremental.md) | P2a | File-based codegen cache + OpenAPI surface. TBD. | ### later/ @@ -71,7 +72,8 @@ Blocking: docs-sweep blocks llm-skills ## v5.1 extras (no phase doc — tracked as bullets) - `bodyParsing: 'parsed' | 'raw' | 'none'` modes + parser registry (`app.parsers`) -- `multipartScalar` helper, `File` type export +- `File` type export — ✅ shipped (beta.51) +- **Route-level multipart single-element extraction** — let a route declare which multipart fields are scalar; the router unwraps their single-element arrays **before** validation, so the schema stays the clean logical shape (`avatar: z.instanceof(File)`) and codegen reads that output type directly. Chosen over a schema-side `multipartScalar` helper (that wrapper was prototyped and dropped — it pushed a parser concern into the schema/types). Interim: validator-native `.array().length(1).transform(...)`. Revisits the parser-side `getFieldShape` idea from `decisions.md` → "Multipart parser is always-array" as an opt-in route convenience. Not scheduled. - Project-side boot hook (`bootHttp(app)` for ad-hoc routes / globals) - `npm run cli routes` (registry walker for "what's mounted in my app") diff --git a/.plans/refactor/queued/yup-optional.md b/.plans/refactor/queued/yup-optional.md new file mode 100644 index 0000000..f23f3e2 --- /dev/null +++ b/.plans/refactor/queued/yup-optional.md @@ -0,0 +1,94 @@ +# P1k — Yup optional (un-bundle the validator) + +**Status**: ⏸ deferred to v5.1 +**Depends on**: P1a-runtime (Standard Schema dispatch + `standardSchemaDriver` shipped) +**Time**: ~½ day +**Parallelizable with**: everything (isolated to the validate layer + 3 built-in files) +**Origin**: surfaced 2026-05-31 — yup is in `dependencies` only because ~6 trivial built-in schemas (Auth + Pagination + `YupFile`) use it. Goal: framework ships **no** bundled validator, while keeping typed route generation. P1a left this as out-of-scope ("future migration to inline Standard Schema, ~250 lines"). + +## Goal + +Move yup out of `dependencies`. Rewrite the handful of built-in schemas as hand-authored Standard Schema objects via a tiny `defineSchema` helper. Codegen keeps producing typed handler requests unchanged — it already reads `StandardSchemaV1.InferOutput`, which is validator-agnostic. + +## Why + +- **Abstract, no useless deps.** The runtime is already Standard-Schema-based (P1a). Yup is the last bundled validator, dragged in only by built-in defaults. Modern users mostly bring zod. +- **Type generation already supports this.** `emit.ts` emits `StandardSchemaV1.InferOutput<...['request']>` — it reads the schema's phantom `~standard.types.output`, so a hand-declared `defineSchema` output type flows through with **zero codegen changes**. +- **Yup support is retained, just un-bundled.** The `yupDriver` has no top-level yup import (duck-typed); it keeps working for users who add yup as an optional peer. + +## Files touched + +- `src/services/validate/defineSchema.ts` (new) — the helper (see API). ~10 lines. +- `src/controllers/Auth.ts` — replace the 5 `object().shape({...})` schemas with `defineSchema`. Drop `import { object, string } from 'yup'`. +- `src/services/http/middleware/Pagination.ts` — replace yup query schema with `defineSchema`. +- `src/helpers/yup.ts` — **unchanged**. `YupFile` stays as the yup-specific file validator. It is only loaded when a user `import`s it, and those users have yup (now an optional peer) installed. **No built-in controller validates files**, so file validation is entirely outside P1k's path. The vendor-neutral file story (`File` export + `multipartScalar`) is a separate track — see Out of scope and `decisions.md` → "Standard-Schema-only file validation". +- `src/locales/*` — ensure i18n keys for hand-rolled messages (`validation.email`, `validation.required`, pagination keys) exist; reuse existing yup message keys where possible so wire/i18n output is unchanged. +- `package.json` — yup `^1.7.0`: `dependencies` → `devDependencies` (keeps fixtures + `yupDriver` tests running). Add `yup` to `peerDependenciesMeta` as optional (alongside `zod`). +- **Unchanged on purpose**: `src/tests/fixtures/controllers/SomeController.ts` + `src/services/validate/ValidateService.test.ts` keep using yup — they are the living test of the `yupDriver` vendor path (yup is now a devDependency). + +## API + +```ts +// src/services/validate/defineSchema.ts +import type { StandardSchemaV1 } from './types.ts'; + +/** + * Wrap a validate function into a Standard Schema object — zero deps. + * `Output` is what codegen reads for handler types (InferOutput); you declare + * it, the runtime checks live in `validate`. Unknown keys are stripped by + * construction (return only known fields in the success `value`). + */ +export function defineSchema( + validate: (value: unknown) => StandardSchemaV1.Result, +): StandardSchemaV1 { + return { '~standard': { version: 1, vendor: 'framework', validate } }; +} +``` + +```ts +// migrated Auth login schema +type LoginRequest = { email: string; password: string }; +const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +const loginSchema = defineSchema((value) => { + const v = (value ?? {}) as Record; + const issues: StandardSchemaV1.Issue[] = []; + if (typeof v.email !== 'string' || !EMAIL.test(v.email)) + issues.push({ message: 'validation.email', path: ['email'] }); + if (typeof v.password !== 'string' || !v.password) + issues.push({ message: 'validation.required', path: ['password'] }); + if (issues.length) return { issues }; + return { value: { email: v.email as string, password: v.password as string } }; +}); +``` + +No new driver: `standardSchemaDriver` already handles any non-yup `~standard` object — calls `validate`, builds the framework `ValidationError` from `issues`, i18n-translates by `message` key. + +## Test plan + +- ☐ `defineSchema.test.ts` — success returns `{value}` with unknowns stripped; failure returns `{issues}`; type-level assert `InferOutput` equals `LoginRequest`. +- ☐ Auth integration tests stay green — login/register reject invalid email + missing password with the **same wire shape** as before. +- ☐ `ValidationError` byte-identical response test (existing cross-phase fixture) stays green. +- ☐ i18n: invalid-field messages still translate (keys reused). +- ☐ `npm run gen && tsc --noEmit` — generated handler `request` types still resolve via `InferOutput` on `defineSchema`. +- ☐ `grep -rn "from 'yup'" src/` returns only `helpers/yup.ts` (YupFile — user opt-in), `tests/fixtures/.../SomeController.ts`, `ValidateService.test.ts`. The **runtime + built-in controllers (Auth, Pagination)** are yup-free. +- ☐ Fresh install without yup in production deps boots and serves `/auth/login` validation. + +## Out of scope + +- **No schema-builder combinators** (`string()`, `object()`, `min()`, `email()`). That is rebuilding zod and owning its edge-case bugs — the explicit anti-goal. If `defineSchema`'s `if`-checks ever feel too verbose across *many* schemas, the answer is "install zod," not "grow this helper." +- **Not removing yup support.** `yupDriver` stays shipped; yup stays an optional peer. +- **File validation.** No built-in validates files, so P1k does not touch it. `YupFile` stays as the yup-specific helper. The vendor-neutral file path (`File` type export, `multipartScalar`, optional `fileSchema()`) is its own track per `decisions.md` → "Standard-Schema-only file validation" / "Multipart parser is always-array" — out of scope here. +- **Not migrating user apps** or user-facing schemas. +- **Full docs rewrite** — `05-models.md`/validation docs adding `defineSchema` is folded into P1g docs-sweep; this phase only ships the code + a CHANGELOG note. + +## Done when + +- `yup` is **not** in `package.json#dependencies`; `defineSchema` + `fileSchema` ship; `Auth.ts` + `Pagination.ts` import no yup; all existing tests green; `npm run gen && tsc --noEmit` clean. + +## Notes + +- **The one cost**: `defineSchema`'s declared `Output` type and its `validate` body are two hand-synced declarations — no `InferType` deriving one from the other, and codegen trusts the declared type without cross-checking `validate`. Acceptable for ~6 fixed, rarely-touched built-in schemas; **not** a pattern to push on large user apps (there, zod). +- `defineSchema` is a useful **public** export: a zero-dep escape hatch for simple validators. Reinforces the "framework bundles no validator; bring zod/yup for rich needs" story. +- Stripping is automatic — success `value` only carries the keys you copy, so the yup `stripUnknown` security property (8 spread-into-`Model.create` sites audited in P1a) is preserved by construction. +- **Migration note (CHANGELOG):** users who use `YupFile` — or any yup schema in their own controllers — must add `yup` to *their own* `dependencies` after P1k. It is no longer provided transitively by the framework. This is correct semantics (depend on the validator you use), but it is a user-visible breaking change to call out. diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b8d95..0c7bbbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,12 @@ Main feature of that release is full TypeScript support insluding mongoose model ## [5.0.0-beta.51] - **[NEW]** `KeyValue` model: a minimal persistent key/value store backed by MongoDB for lightweight caching, runtime config, and feature flags. +- **[NEW]** `defineSchema(validate)` helper (`@adaptivestone/framework/services/validate/defineSchema.js`) — wrap a plain validate function into a zero-dependency Standard Schema. Codegen reads its `Output` generic for handler request types via `StandardSchemaV1.InferOutput`. +- **[NEW]** `File` type exported from `@adaptivestone/framework/types.js` — vendor-neutral uploaded-file type (aliases formidable's `PersistentFile` today; re-points at the web-standard `File` after the P3 parser swap). Validate uploads with your validator's idiom, e.g. `z.instanceof(File)`. +- **[NEW]** Content-type-keyed request schemas: a route's `request` can be a map (`{ 'application/json': schemaA, 'multipart/form-data': schemaB }`, mirrors OpenAPI `requestBody.content`). The framework validates with the schema matching the request's `Content-Type` (415 on no match) and `req.appInfo.request` becomes a `contentType`-discriminated union; codegen emits the union automatically. Media-type matching is case-insensitive and ignores parameters (`; charset=...`); `contentType` is a reserved field on the validated request object. +- **[CHANGE]** Built-in `Auth` controller and `Pagination` middleware now validate with `defineSchema` instead of yup. The framework runtime and built-ins are yup-free. +- **[BREAKING]** `yup` moved from `dependencies` to an optional `peerDependency`. It is no longer bundled. Apps that use yup schemas (including `YupFile`) must add `yup` to their own `dependencies`. Zod/Valibot/ArkType users are unaffected. +- **[DEPRECATED]** `YupFile` (`@adaptivestone/framework/helpers/yup.js`) — removed in v6. Validate files via the new `File` export + your validator's `instanceof` idiom instead. --- ## [5.0.0-beta.50] diff --git a/package.json b/package.json index 9558533..f8ac759 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,7 @@ "mongoose": "^9.0.0", "rate-limiter-flexible": "^11.0.0", "winston": "^3.3.3", - "winston-transport": "^4.9.0", - "yup": "^1.7.0" + "winston-transport": "^4.9.0" }, "devDependencies": { "@biomejs/biome": "^2.0.6", @@ -67,12 +66,14 @@ "mongodb-memory-server": "^11.0.0", "typescript": "^6.0.0", "vitest": "^4.0.0", + "yup": "^1.7.0", "zod": "^4.0.0" }, "peerDependencies": { "@adaptivestone/framework-module-email": "^1.0.0", "@sentry/core": "^10.34.0", "@sentry/node": "^10.34.0", + "yup": "^1.7.0", "zod": "^3.24.0 || ^4.0.0" }, "peerDependenciesMeta": { @@ -85,6 +86,9 @@ "@sentry/node": { "optional": true }, + "yup": { + "optional": true + }, "zod": { "optional": true } diff --git a/src/codegen/collectMetadata.ts b/src/codegen/collectMetadata.ts index 8f5d8c1..1d38de6 100644 --- a/src/codegen/collectMetadata.ts +++ b/src/codegen/collectMetadata.ts @@ -9,6 +9,7 @@ */ import type AbstractController from '../modules/AbstractController.ts'; +import { isContentTypeRequestMap } from '../services/validate/contentType.ts'; /** One middleware reference, with its parameters if it was declared as a tuple. */ export interface MiddlewareRef { @@ -25,6 +26,12 @@ export interface RouteMeta { handlerName: string | null; /** True when the route entry is `{ handler, request }` and `request` is set. */ hasSchema: boolean; + /** + * Media-type keys when `request` is a content-type map (`{ 'application/json': + * schema, ... }`). Drives the discriminated-union request type. Absent for a + * single-schema `request`. + */ + requestContentTypes?: string[]; /** True when the route entry is `{ handler, query }` and `query` is set. */ hasQuerySchema: boolean; } @@ -87,6 +94,9 @@ function extractRouteMeta( handlerName: typeof obj.handler === 'function' ? nameOf(obj.handler) : null, hasSchema: obj.request != null, + requestContentTypes: isContentTypeRequestMap(obj.request) + ? Object.keys(obj.request) + : undefined, hasQuerySchema: obj.query != null, }; } diff --git a/src/codegen/emit.test.ts b/src/codegen/emit.test.ts index 5f22c77..ba7f7a5 100644 --- a/src/codegen/emit.test.ts +++ b/src/codegen/emit.test.ts @@ -204,3 +204,46 @@ export default Standalone; expect(output).toContain('UnionAppInfoProvides'); }); }); + +describe('emit content-type request map', () => { + it('emits a contentType-discriminated union for a content-type map request', async () => { + const srcPath = await writeFile( + 'Upload.ts', + `import AbstractController from '@adaptivestone/framework/modules/AbstractController.js'; + +class Upload extends AbstractController { + get routes() { + return { post: { '/upload': { handler: this.upload, request: {} } } }; + } + async upload() { return; } +} +export default Upload; +`, + ); + + const controller: ControllerMeta = { + className: 'Upload', + prefix: '', + urlPrefix: '/upload', + routes: [ + { + method: 'post', + path: '/upload', + handlerName: 'upload', + hasSchema: true, + requestContentTypes: ['application/json', 'multipart/form-data'], + hasQuerySchema: false, + } satisfies RouteMeta, + ], + }; + + const output = await emitGenFile({ controller, srcPath, chains: [[]] }); + + expect(output).toContain("{ contentType: 'application/json' }"); + expect(output).toContain("['request']['application/json']"); + expect(output).toContain("{ contentType: 'multipart/form-data' }"); + expect(output).toContain("['request']['multipart/form-data']"); + // The two media-type branches are joined as a union. + expect(output).toMatch(/\) \| \(/); + }); +}); diff --git a/src/codegen/emit.ts b/src/codegen/emit.ts index f3a0349..36e2fee 100644 --- a/src/codegen/emit.ts +++ b/src/codegen/emit.ts @@ -214,9 +214,25 @@ function renderShape( // on the route entry. const appInfoOverrides: string[] = []; if (route.hasSchema) { - appInfoOverrides.push( - `request: StandardSchemaV1.InferOutput<${routesAlias}['${route.method}']['${route.path}']['request']>`, - ); + if (route.requestContentTypes?.length) { + // Content-type map → discriminated union keyed by `contentType`. Each + // branch reads InferOutput of that media type's schema. + const base = `${routesAlias}['${route.method}']['${route.path}']['request']`; + // Discriminant literal is lower-cased to match the runtime-injected + // value (the parser normalizes `Content-Type` to lower case); the type + // navigation keeps the author's original key so the schema resolves. + const union = route.requestContentTypes + .map( + (ct) => + `({ contentType: '${ct.toLowerCase()}' } & StandardSchemaV1.InferOutput<${base}['${ct}']>)`, + ) + .join(' | '); + appInfoOverrides.push(`request: ${union}`); + } else { + appInfoOverrides.push( + `request: StandardSchemaV1.InferOutput<${routesAlias}['${route.method}']['${route.path}']['request']>`, + ); + } } if (route.hasQuerySchema) { appInfoOverrides.push( diff --git a/src/controllers/Auth.test.ts b/src/controllers/Auth.test.ts index 497f6ea..7988b9e 100644 --- a/src/controllers/Auth.test.ts +++ b/src/controllers/Auth.test.ts @@ -150,6 +150,18 @@ describe('auth', () => { expect(status).toBe(400); }); + it('rejects a non-string password with 400 (not 500)', async () => { + expect.assertions(1); + + const { status } = await fetch(getTestServerURL('/auth/login'), { + method: 'POST', + headers: { 'Content-type': 'application/json' }, + body: JSON.stringify({ email: 'a@b.com', password: ['x'] }), + }).catch(() => ({ status: 500 })); + + expect(status).toBe(400); + }); + it('can login with normal creds and verified email', async () => { expect.assertions(3); const UserModel = appInstance.getModel('User') as unknown as TUser; diff --git a/src/controllers/Auth.ts b/src/controllers/Auth.ts index bc4235a..fcb77d7 100644 --- a/src/controllers/Auth.ts +++ b/src/controllers/Auth.ts @@ -1,9 +1,10 @@ import type { Response } from 'express'; -import { object, string } from 'yup'; import type { TUser } from '../models/User.ts'; import AbstractController from '../modules/AbstractController.ts'; import GetUserByToken from '../services/http/middleware/GetUserByToken.ts'; import RateLimiter from '../services/http/middleware/RateLimiter.ts'; +import { defineSchema } from '../services/validate/defineSchema.ts'; +import type { StandardSchemaV1 } from '../services/validate/types.ts'; import type { PostLoginRequest, PostLogoutRequest, @@ -16,58 +17,192 @@ import type { type UserInstance = InstanceType; +// Zero-dependency validation for the built-in auth routes. Messages are i18n +// keys (see `src/locales/*`); the framework auto-translates them via the +// request's `i18n.t` before the error is serialized. +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +const PASSWORD_RE = /^[a-zA-Z0-9!@#$%ˆ^&*()_+\-{}[\]<>]+$/; +const NICK_RE = /^[a-zA-Z0-9_\-.]+$/; +const isMissing = (v: unknown) => v === undefined || v === null || v === ''; +// Coerce primitives to string the way yup's `string()` did, so numeric/boolean +// JSON values keep validating (e.g. `password: 123` → `"123"`). `null`, +// `undefined`, and non-primitives (arrays/objects) pass through unchanged — yup +// did not stringify those either, so the type/required checks still fire. +const coerceStr = (v: unknown): unknown => + v == null || typeof v === 'object' ? v : String(v); + +const pushEmailIssues = (email: unknown, issues: StandardSchemaV1.Issue[]) => { + if (isMissing(email)) { + issues.push({ message: 'auth.emailProvided', path: ['email'] }); + } else if (typeof email !== 'string' || !EMAIL_RE.test(email)) { + issues.push({ message: 'auth.emailValid', path: ['email'] }); + } +}; + class Auth extends AbstractController { get routes() { return { post: { '/login': { handler: this.postLogin, - request: object().shape({ - email: string().email().required('auth.emailProvided'), // if not provided then error will be generated - password: string().required('auth.passwordProvided'), // possible to provide values from translation - }), + request: defineSchema<{ email: string; password: string }>( + (value) => { + const v = (value ?? {}) as Record; + const email = coerceStr(v.email); + const password = coerceStr(v.password); + const issues: StandardSchemaV1.Issue[] = []; + pushEmailIssues(email, issues); + if (isMissing(password) || typeof password !== 'string') { + issues.push({ + message: 'auth.passwordProvided', + path: ['password'], + }); + } + if (issues.length) { + return { issues }; + } + return { + value: { + email: email as string, + password: password as string, + }, + }; + }, + ), }, '/register': { handler: this.postRegister, - request: object().shape({ - email: string() - .email('auth.emailValid') - .required('auth.emailProvided'), - password: string() - .matches( - /^[a-zA-Z0-9!@#$%ˆ^&*()_+\-{}[\]<>]+$/, - 'auth.passwordValid', - ) - .required('auth.passwordProvided'), - nickName: string().matches( - /^[a-zA-Z0-9_\-.]+$/, - 'auth.nickNameValid', - ), - firstName: string(), - lastName: string(), + request: defineSchema<{ + email: string; + password: string; + nickName?: string; + firstName?: string; + lastName?: string; + }>((value) => { + const v = (value ?? {}) as Record; + const email = coerceStr(v.email); + const password = coerceStr(v.password); + const nickName = coerceStr(v.nickName); + const firstName = coerceStr(v.firstName); + const lastName = coerceStr(v.lastName); + const issues: StandardSchemaV1.Issue[] = []; + pushEmailIssues(email, issues); + if (isMissing(password)) { + issues.push({ + message: 'auth.passwordProvided', + path: ['password'], + }); + } else if ( + typeof password !== 'string' || + !PASSWORD_RE.test(password) + ) { + issues.push({ + message: 'auth.passwordValid', + path: ['password'], + }); + } + // nickName is optional, but an empty string is invalid (matches the + // old yup `.matches()` behavior — only `null`/`undefined` skip). + if ( + nickName != null && + (typeof nickName !== 'string' || !NICK_RE.test(nickName)) + ) { + issues.push({ + message: 'auth.nickNameValid', + path: ['nickName'], + }); + } + // firstName/lastName are optional free-form strings, but a non-string + // (array/object) would otherwise reach `User.create` and fail the + // Mongoose String cast with a 500. Reject it as a 400 (yup parity). + if (firstName != null && typeof firstName !== 'string') { + issues.push({ message: 'auth.nameValid', path: ['firstName'] }); + } + if (lastName != null && typeof lastName !== 'string') { + issues.push({ message: 'auth.nameValid', path: ['lastName'] }); + } + if (issues.length) { + return { issues }; + } + return { + value: { + email: email as string, + password: password as string, + nickName: nickName as string | undefined, + firstName: firstName as string | undefined, + lastName: lastName as string | undefined, + }, + }; }), }, '/logout': this.postLogout, '/verify': this.verifyUser, '/send-recovery-email': { handler: this.sendPasswordRecoveryEmail, - request: object().shape({ email: string().email().required() }), + request: defineSchema<{ email: string }>((value) => { + const v = (value ?? {}) as Record; + const email = coerceStr(v.email); + const issues: StandardSchemaV1.Issue[] = []; + pushEmailIssues(email, issues); + if (issues.length) { + return { issues }; + } + return { value: { email: email as string } }; + }), }, '/recover-password': { handler: this.recoverPassword, - request: object().shape({ - password: string() - .matches( - /^[a-zA-Z0-9!@#$%ˆ^&*()_+\-{}[\]<>]+$/, - 'auth.passwordValid', - ) - .required(), - passwordRecoveryToken: string().required(), + request: defineSchema<{ + password: string; + passwordRecoveryToken: string; + }>((value) => { + const v = (value ?? {}) as Record; + const password = coerceStr(v.password); + const passwordRecoveryToken = coerceStr(v.passwordRecoveryToken); + const issues: StandardSchemaV1.Issue[] = []; + if (isMissing(password)) { + issues.push({ + message: 'auth.passwordProvided', + path: ['password'], + }); + } else if ( + typeof password !== 'string' || + !PASSWORD_RE.test(password) + ) { + issues.push({ + message: 'auth.passwordValid', + path: ['password'], + }); + } + if (isMissing(passwordRecoveryToken)) { + issues.push({ + message: 'auth.passwordRecoveryTokenProvided', + path: ['passwordRecoveryToken'], + }); + } + if (issues.length) { + return { issues }; + } + return { + value: { + password: password as string, + passwordRecoveryToken: passwordRecoveryToken as string, + }, + }; }), }, '/send-verification': { handler: this.sendVerification, - request: object().shape({ email: string().email().required() }), + request: defineSchema<{ email: string }>((value) => { + const v = (value ?? {}) as Record; + const email = coerceStr(v.email); + const issues: StandardSchemaV1.Issue[] = []; + pushEmailIssues(email, issues); + if (issues.length) { + return { issues }; + } + return { value: { email: email as string } }; + }), }, }, }; diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 4a9cba3..570db26 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -17,6 +17,10 @@ import type { } from '../services/http/routing/RouteNode.ts'; import { HTTP_METHODS } from '../services/http/routing/RouteNode.ts'; import { createNode } from '../services/http/routing/RouteRegistry.ts'; +import { + isContentTypeRequestMap, + normalizeContentType, +} from '../services/validate/contentType.ts'; import type { StandardSchemaV1 } from '../services/validate/types.ts'; import ValidateService from '../services/validate/ValidateService.ts'; @@ -272,11 +276,31 @@ class ControllerManager extends Base { } } - const requestSchemas: StandardSchemaV1[] = []; - if (entry.request) { - requestSchemas.push(entry.request); + // Route-entry request schema: a single Standard Schema, or a content-type + // map resolved per-request by `Content-Type`. The map is resolved inside + // the request handler (it depends on the incoming header); middleware + // schemas are content-type-agnostic and resolved once here. + // + // The lookup table is a null-prototype object with lower-cased keys: this + // makes matching case-insensitive AND prevents a header like `__proto__` + // or `constructor` from resolving to an inherited `Object.prototype` + // member (which would otherwise be truthy and bypass the 415). + const entryRequest = entry.request as + | StandardSchemaV1 + | Record + | undefined; + let entryRequestMapLookup: Record | null = null; + let entryRequestMapKeys: string[] = []; + if (isContentTypeRequestMap(entryRequest)) { + entryRequestMapLookup = Object.create(null) as Record< + string, + StandardSchemaV1 + >; + entryRequestMapKeys = Object.keys(entryRequest); + for (const key of entryRequestMapKeys) { + entryRequestMapLookup[key.toLowerCase()] = entryRequest[key]; + } } - requestSchemas.push(...middlewareRequestSchemas); const querySchemas: StandardSchemaV1[] = []; if (entry.query) { @@ -290,6 +314,28 @@ class ControllerManager extends Base { next: NextFunction, ): Promise => { try { + const requestSchemas: StandardSchemaV1[] = []; + let resolvedContentType: string | null = null; + if (entryRequestMapLookup) { + const contentType = normalizeContentType(req.headers['content-type']); + const matched = + contentType && Object.hasOwn(entryRequestMapLookup, contentType) + ? entryRequestMapLookup[contentType] + : undefined; + if (!matched) { + return res.status(415).json({ + message: `Unsupported Content-Type. Expected one of: ${entryRequestMapKeys.join( + ', ', + )}`, + }); + } + requestSchemas.push(matched); + resolvedContentType = contentType; + } else if (entryRequest) { + requestSchemas.push(entryRequest as StandardSchemaV1); + } + requestSchemas.push(...middlewareRequestSchemas); + if (requestSchemas.length > 0) { const parts = await Promise.all( requestSchemas.map( @@ -301,6 +347,10 @@ class ControllerManager extends Base { ), ); req.appInfo.request = Object.assign({}, ...parts); + if (resolvedContentType) { + (req.appInfo.request as Record).contentType = + resolvedContentType; + } } if (querySchemas.length > 0) { const parts = await Promise.all( diff --git a/src/helpers/yup.test.ts b/src/helpers/yup.test.ts new file mode 100644 index 0000000..b493393 --- /dev/null +++ b/src/helpers/yup.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { YupFile } from './yup.ts'; + +describe('YupFile (deprecated)', () => { + it('emits a DeprecationWarning once, regardless of how many are constructed', async () => { + const captured: Array<{ name: string; code?: string }> = []; + const handler = (warning: Error & { code?: string }) => + captured.push({ name: warning.name, code: warning.code }); + + process.on('warning', handler); + try { + // biome-ignore lint/correctness/noUnusedVariables: constructed for the side effect + const a = new YupFile(); + // biome-ignore lint/correctness/noUnusedVariables: constructed for the side effect + const b = new YupFile(); + // process.emitWarning fires on the next tick — let it flush. + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off('warning', handler); + } + + const yupFileWarnings = captured.filter( + (w) => w.code === 'ASF_DEP_YUPFILE', + ); + expect(yupFileWarnings).toHaveLength(1); + expect(yupFileWarnings[0]?.name).toBe('DeprecationWarning'); + }); +}); diff --git a/src/helpers/yup.ts b/src/helpers/yup.ts index 3c73ece..408980c 100644 --- a/src/helpers/yup.ts +++ b/src/helpers/yup.ts @@ -1,9 +1,24 @@ import { PersistentFile } from 'formidable'; import { Schema } from 'yup'; +// Emit the runtime deprecation notice at most once per process. Use Node's +// DeprecationWarning channel so consumers can silence it (`--no-deprecation`), +// trace it (`--trace-deprecation`), or escalate it to a thrown error +// (`--throw-deprecation`) without us hard-breaking anyone before v6. +let deprecationWarned = false; + /** - * Validator for file - * use as + * Validator for an uploaded file (yup-specific). + * + * @deprecated Since 5.0.0-beta.51 — `YupFile` will be removed in v6. Validate + * files with the framework's vendor-neutral `File` type and your validator's + * `instanceof` idiom instead (no yup required): + * + * import { File } from '@adaptivestone/framework/types.js'; + * // zod: z.instanceof(File) + * // valibot: v.instance(File) + * // yup: mixed().test('file', 'not a file', (v) => v instanceof File) + * * @example * request: yup.object().shape({ * someFile: new YupFile().required(), @@ -17,6 +32,13 @@ class YupFile extends Schema { Array.isArray(value) && value.every((item) => item instanceof PersistentFile), }); + if (!deprecationWarned) { + deprecationWarned = true; + process.emitWarning( + 'YupFile is deprecated and will be removed in v6. Validate uploaded files with the `File` export from "@adaptivestone/framework/types.js" and your validator\'s instanceof idiom (e.g. z.instanceof(File)).', + { type: 'DeprecationWarning', code: 'ASF_DEP_YUPFILE' }, + ); + } } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c887759..fb3aa20 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -4,6 +4,8 @@ "passwordProvided": "Password must be provided", "emailProvided": "Email must be provided", "nickNameValid": "Nick name is not valid,only a-z,A-Z,0-9", + "nameValid": "Name is not valid", + "passwordRecoveryTokenProvided": "Password recovery token must be provided", "passwordValid": "Password is not valid,only a-z,A-Z,0-9,!,@,#,$,%,ˆ,&,*,(,),_,+,{,},[,],<,>", "passwordTooShort": "Password must be at least {{min}} characters", "emailValid": "Email is not valid", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 696b226..029be9a 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -4,6 +4,8 @@ "passwordProvided": "Нужно ввести пароль", "emailProvided": "Нужно указать Email", "nickNameValid": "Неверный формат ника,только a-z,A-Z,0-9", + "nameValid": "Неверное имя", + "passwordRecoveryTokenProvided": "Нужно указать токен восстановления пароля", "passwordValid": "Неверный формат пароля,только a-z,A-Z,0-9,!,@,#,$,%,ˆ,&,*,(,),_,+,{,},[,],<,>", "passwordTooShort": "Пароль должен быть не менее {{min}} символов", "emailValid": "Неверный формат Email-а", diff --git a/src/modules/AbstractController.ts b/src/modules/AbstractController.ts index b56c06f..db64686 100644 --- a/src/modules/AbstractController.ts +++ b/src/modules/AbstractController.ts @@ -3,6 +3,7 @@ import type AbstractMiddleware from '../services/http/middleware/AbstractMiddlew import Auth from '../services/http/middleware/Auth.ts'; import GetUserByToken from '../services/http/middleware/GetUserByToken.ts'; import type { BodyParsingMode } from '../services/http/routing/RouteNode.ts'; +import type { RequestContentTypeMap } from '../services/validate/contentType.ts'; import type { StandardSchemaV1 } from '../services/validate/types.ts'; import Base from './Base.ts'; @@ -19,7 +20,17 @@ type RouteObject = { handler: RouteHandler; description?: string; middleware?: TMiddleware | null; - request?: StandardSchemaV1 | null; + /** + * Body schema. Either a single Standard Schema (validates any body), or a + * content-type map (`{ 'application/json': schema, 'multipart/form-data': + * schema }`) validated by the request's `Content-Type` — mirrors OpenAPI's + * `requestBody.content`. Media-type matching is case-insensitive and ignores + * parameters (`; charset=...`). With a map, `req.appInfo.request` is a + * discriminated union keyed by a reserved `contentType` field — it holds the + * matched media type and overwrites any body field of the same name, so do + * not declare a schema field named `contentType`. + */ + request?: StandardSchemaV1 | RequestContentTypeMap | null; query?: StandardSchemaV1 | null; bodyParsing?: BodyParsingMode; }; diff --git a/src/services/http/files.ts b/src/services/http/files.ts new file mode 100644 index 0000000..f7c83ab --- /dev/null +++ b/src/services/http/files.ts @@ -0,0 +1,18 @@ +/** + * Uploaded-file type. Today it aliases formidable's `PersistentFile`; the + * future transport-neutral parser swap (P3) re-points it at the web-standard + * `File`. Import this instead of the concrete parser class so your file + * validation survives that swap untouched. + * + * Validate uploads with your validator's idiomatic `instanceof` check: + * + * import { File } from '@adaptivestone/framework/types.js'; + * // zod: z.instanceof(File) + * // valibot: v.instance(File) + * // arktype: type.instanceOf(File) + * // yup: mixed().test('file', 'not a file', (v) => v instanceof File) + * + * `File` is both a value (for `instanceof`) and a type (for annotations), + * because it is a class. + */ +export { PersistentFile as File } from 'formidable'; diff --git a/src/services/http/middleware/Pagination.ts b/src/services/http/middleware/Pagination.ts index 03458c7..c3dbd00 100644 --- a/src/services/http/middleware/Pagination.ts +++ b/src/services/http/middleware/Pagination.ts @@ -1,5 +1,6 @@ import type { NextFunction, Response } from 'express'; -import { number, object } from 'yup'; +import { defineSchema } from '../../validate/defineSchema.ts'; +import type { StandardSchemaV1 } from '../../validate/types.ts'; import type { FrameworkRequest } from '../HttpServer.ts'; import AbstractMiddleware from './AbstractMiddleware.ts'; @@ -26,9 +27,25 @@ class Pagination extends AbstractMiddleware { } get relatedQueryParameters() { - return object().shape({ - page: number(), - limit: number(), + return defineSchema<{ page?: number; limit?: number }>((value) => { + const v = (value ?? {}) as Record; + const issues: StandardSchemaV1.Issue[] = []; + const out: { page?: number; limit?: number } = {}; + for (const key of ['page', 'limit'] as const) { + if (v[key] === undefined || v[key] === null || v[key] === '') { + continue; + } + const n = Number(v[key]); + if (Number.isNaN(n)) { + issues.push({ message: `${key} must be a number`, path: [key] }); + } else { + out[key] = n; + } + } + if (issues.length) { + return { issues }; + } + return { value: out }; }); } diff --git a/src/services/http/routing/RouteNode.ts b/src/services/http/routing/RouteNode.ts index 46f1c57..ed9b7ba 100644 --- a/src/services/http/routing/RouteNode.ts +++ b/src/services/http/routing/RouteNode.ts @@ -6,6 +6,7 @@ * shape. */ +import type { RequestContentTypeMap } from '../../validate/contentType.ts'; import type { StandardSchemaV1 } from '../../validate/types.ts'; import type AbstractMiddleware from '../middleware/AbstractMiddleware.ts'; @@ -55,7 +56,7 @@ export interface MiddlewareEntry { export interface HandlerEntry { // biome-ignore lint/complexity/noBannedTypes: handlers are user-provided callables of varying shape handler: Function; - request?: StandardSchemaV1; + request?: StandardSchemaV1 | RequestContentTypeMap; query?: StandardSchemaV1; middlewares?: MiddlewareEntry[]; bodyParsing?: BodyParsingMode; diff --git a/src/services/validate/contentType.ts b/src/services/validate/contentType.ts new file mode 100644 index 0000000..e39ceb6 --- /dev/null +++ b/src/services/validate/contentType.ts @@ -0,0 +1,44 @@ +import type { StandardSchemaV1 } from './types.ts'; + +/** + * A route `request` can be a single Standard Schema (validates any body) or a + * **content-type map** — `{ 'application/json': schemaA, 'multipart/form-data': + * schemaB }` — which validates with the schema matching the request's + * `Content-Type`. The map shape mirrors OpenAPI's `requestBody.content`. + */ +export type RequestContentTypeMap = Record; + +/** + * Distinguish a content-type map from a Standard Schema. A schema carries + * `~standard`; a map does not, and its keys are media types (contain `/`). + */ +export function isContentTypeRequestMap( + request: unknown, +): request is RequestContentTypeMap { + if (typeof request !== 'object' || request === null) { + return false; + } + if ('~standard' in request) { + return false; + } + const keys = Object.keys(request); + return keys.length > 0 && keys.every((key) => key.includes('/')); +} + +/** + * Normalize a `Content-Type` header to its media type, dropping parameters + * (`; charset=...`, `; boundary=...`) and casing. Returns `null` when absent. + */ +export function normalizeContentType( + header: string | string[] | undefined, +): string | null { + if (!header) { + return null; + } + const value = Array.isArray(header) ? header[0] : header; + if (typeof value !== 'string') { + return null; + } + const mediaType = value.split(';')[0]?.trim().toLowerCase(); + return mediaType || null; +} diff --git a/src/services/validate/defineSchema.ts b/src/services/validate/defineSchema.ts new file mode 100644 index 0000000..6805abc --- /dev/null +++ b/src/services/validate/defineSchema.ts @@ -0,0 +1,32 @@ +import type { StandardSchemaV1 } from './types.ts'; + +/** + * Wrap a validate function into a Standard Schema object — zero dependencies. + * + * The `Output` generic is what the codegen reads for handler request types + * (`StandardSchemaV1.InferOutput`): you declare it, the runtime checks live in + * `validate`. Return only the known keys in the success `value` to strip + * unknown input by construction. + * + * Use this for simple, dependency-free schemas. For richer validation, bring a + * Standard Schema library (zod, valibot, arktype, yup ≥1.7) as a route schema — + * the framework dispatches it the same way. + * + * @example + * const loginSchema = defineSchema<{ email: string }>((value) => { + * const v = (value ?? {}) as Record; + * if (typeof v.email !== 'string') { + * return { issues: [{ message: 'email required', path: ['email'] }] }; + * } + * return { value: { email: v.email } }; + * }); + */ +export function defineSchema( + validate: ( + value: unknown, + ) => + | StandardSchemaV1.Result + | Promise>, +): StandardSchemaV1 { + return { '~standard': { version: 1, vendor: 'framework', validate } }; +} diff --git a/src/tests/fixtures/controllers/SomeController.test.ts b/src/tests/fixtures/controllers/SomeController.test.ts index b946200..9c0ab59 100644 --- a/src/tests/fixtures/controllers/SomeController.test.ts +++ b/src/tests/fixtures/controllers/SomeController.test.ts @@ -272,4 +272,80 @@ describe('middlewares correct works', () => { expect(status).toBe(403); }); + + describe('content-type request map', () => { + const path = '/test/somecontroller/contentTypeBody'; + + it('dispatches to the application/json schema', async () => { + expect.assertions(2); + const res = await fetch(getTestServerURL(path), { + method: 'POST', + headers: { 'Content-type': 'application/json' }, + body: JSON.stringify({ anything: true }), + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.data).toEqual({ + via: 'json', + contentType: 'application/json', + }); + }); + + it('dispatches to the urlencoded schema', async () => { + expect.assertions(2); + const res = await fetch(getTestServerURL(path), { + method: 'POST', + headers: { 'Content-type': 'application/x-www-form-urlencoded' }, + body: 'anything=1', + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.data).toEqual({ + via: 'form', + contentType: 'application/x-www-form-urlencoded', + }); + }); + + it('returns 415 for an unsupported Content-Type', async () => { + expect.assertions(1); + const res = await fetch(getTestServerURL(path), { + method: 'POST', + headers: { 'Content-type': 'application/octet-stream' }, + body: 'rawbytes', + }); + expect(res.status).toBe(415); + }); + + it('matches the Content-Type case-insensitively', async () => { + expect.assertions(2); + const res = await fetch(getTestServerURL(path), { + method: 'POST', + headers: { 'Content-type': 'APPLICATION/JSON' }, + body: JSON.stringify({ anything: true }), + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.data).toEqual({ + via: 'json', + contentType: 'application/json', + }); + }); + + it('does not accept or leak internals for prototype-chain Content-Types', async () => { + expect.assertions(4); + // `constructor` / `__proto__` resolve to truthy `Object.prototype` + // members on a plain-object map; the null-prototype lookup must reject + // them (never 200) and never leak the internal "no driver" message. + for (const ct of ['constructor', '__proto__']) { + const res = await fetch(getTestServerURL(path), { + method: 'POST', + headers: { 'Content-type': ct }, + body: 'x', + }); + const text = await res.text(); + expect(res.status).not.toBe(200); + expect(text).not.toContain('Standard Schema'); + } + }); + }); }); diff --git a/src/tests/fixtures/controllers/SomeController.ts b/src/tests/fixtures/controllers/SomeController.ts index 14bc2ca..d4c2aae 100644 --- a/src/tests/fixtures/controllers/SomeController.ts +++ b/src/tests/fixtures/controllers/SomeController.ts @@ -13,6 +13,7 @@ import GetUserByToken from '../../../services/http/middleware/GetUserByToken.ts' import Pagination from '../../../services/http/middleware/Pagination.ts'; import RateLimiter from '../../../services/http/middleware/RateLimiter.ts'; import RoleMiddleware from '../../../services/http/middleware/Role.ts'; +import { defineSchema } from '../../../services/validate/defineSchema.ts'; import CheckFlag from '../middleware/CheckFlag.ts'; class SomeController extends AbstractController { @@ -68,6 +69,17 @@ class SomeController extends AbstractController { name: string(), }), }, + '/contentTypeBody': { + handler: this.contentTypeBody, + request: { + 'application/json': defineSchema<{ via: string }>(() => ({ + value: { via: 'json' }, + })), + 'application/x-www-form-urlencoded': defineSchema<{ via: string }>( + () => ({ value: { via: 'form' } }), + ), + }, + }, }, patch: { '/userAvatar': { @@ -114,6 +126,12 @@ class SomeController extends AbstractController { }); } + async contentTypeBody(req: FrameworkRequest, res: Response) { + // `req.appInfo.request` is `{ via, contentType }` — the schema selected by + // the request's Content-Type, plus the injected `contentType` discriminant. + return res.status(200).json({ data: req.appInfo.request }); + } + async addPost(req: FrameworkRequest, res: Response) { const { name, discription } = req.appInfo.request; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f84c36c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,4 @@ +/** + * Public type surface — import via `@adaptivestone/framework/types.js`. + */ +export { File } from './services/http/files.ts';