Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tresponse-json-constraint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@toapi/common": patch
"@toapi/server": patch
"@toapi/client": patch
---

Constrain `TResponse.json` to only accept JSON-serializable values (`JSONValue`). This prevents non-JSON types such as `Date`, `Map`, `Set`, `bigint`, `undefined`, functions, or symbols from being passed as structured response data, which would otherwise be silently coerced to strings by `JSON.stringify` and break the type contract the client relies on. Form-data mocks that echoed `Object.fromEntries(formData)` (which can contain `File` values) were updated to return only string entries.
18 changes: 13 additions & 5 deletions packages/toapi-client/src/api.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,15 @@ export const api = defineApi({
{
authorize: () => true,
},
async (req) =>
TResponse.json(Object.fromEntries(await req.formData()), {
cache: { tags: ["movies"] },
}),
async (req) => {
const data: Record<string, string> = {};
for (const [key, value] of await req.formData()) {
if (typeof value === "string") {
data[key] = value;
}
}
return TResponse.json(data, { cache: { tags: ["movies"] } });
},
),
})
.route("/authorized", {
Expand Down Expand Up @@ -192,7 +197,10 @@ export const api = defineApi({
authorize: () => true,
},
async (req) => {
return TResponse.json(req.query());
const query = req.query();
return TResponse.json(
query.optional === undefined ? {} : query,
);
},
),
});
2 changes: 1 addition & 1 deletion packages/toapi-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
}
},
"scripts": {
"build": "tsc --noEmit false",
"build": "tsc -p tsconfig.build.json",
"release": "pnpm build && pnpm publish --no-git-checks",
"test": "vitest"
},
Expand Down
64 changes: 62 additions & 2 deletions packages/toapi-common/src/t-response.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { TResponse } from "./t-response.js";
import { describe, expect, expectTypeOf, test } from "vitest";
import { TResponse, type JSONValue } from "./t-response.js";

describe("TResponse", () => {
test("correctly sets tags-header", () => {
Expand All @@ -22,4 +22,64 @@ describe("TResponse", () => {
const text = await res.text();
expect(text).toBe('{"id":1}\n{"id":2}\n');
});

test("json accepts JSON-serializable values", () => {
// These calls must compile (proving the JSONValue constraint accepts them)
// and are safe to execute at runtime.
TResponse.json("hello");
TResponse.json(42);
TResponse.json(true);
TResponse.json(null);
TResponse.json([1, "two", false, null]);
TResponse.json({ now: "2024-01-01" });
TResponse.json({ nested: { a: 1, list: [true, null, "x"] } });
});

test("json infers the response type from the JSON data", () => {
const objectRes = TResponse.json({ now: "2024-01-01" });
expectTypeOf(objectRes.data).toEqualTypeOf<{ now: string } | undefined>();

const arrayRes = TResponse.json([1, 2, 3]);
expectTypeOf(arrayRes.data).toEqualTypeOf<number[] | undefined>();
});

test("json rejects non-JSON values at compile time", () => {
// The assignments below are expected to fail type-checking because the
// values are not representable as JSON. `@ts-expect-error` consumes the
// error; if a value ever became JSON-compatible the directive would itself
// error, keeping the contract honest. None of these call `JSON.stringify`,
// so they are safe to execute at runtime (bigint/symbol would otherwise
// throw when serialized).

// @ts-expect-error Date serializes to a string, breaking the type boundary
const dateValue: JSONValue = new Date();
// @ts-expect-error bigint throws when serialized and is not JSON
const bigintValue: JSONValue = 1n;
// @ts-expect-error symbol is not JSON-serializable
const symbolValue: JSONValue = Symbol("x");
// @ts-expect-error undefined is not representable in JSON
const undefinedValue: JSONValue = undefined;
// @ts-expect-error Map is not JSON-serializable
const mapValue: JSONValue = new Map();
// @ts-expect-error Set is not JSON-serializable
const setValue: JSONValue = new Set();
// @ts-expect-error functions are not JSON-serializable
const functionValue: JSONValue = () => {};

// End-to-end: the original issue scenario must be rejected.
// Safe at runtime: `JSON.stringify({ now: date })` does not throw.
// @ts-expect-error TResponse.json must not accept Date values
TResponse.json({ now: new Date() });

// reference the locals so they are not dropped by tooling
void [
dateValue,
bigintValue,
symbolValue,
undefinedValue,
mapValue,
setValue,
functionValue,
];
});
});
23 changes: 22 additions & 1 deletion packages/toapi-common/src/t-response.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import { EXPIRES_AT_HEADER, TAGS_HEADER } from "./constants.js";
import type { CookieStore } from "./cookie-store.js";

/**
* A value that can be represented as JSON without coercion.
*
* This is the set of values `JSON.stringify` can round-trip into a structured
* JSON value (rather than coercing it into a string or throwing). Constraining
* {@link TResponse.json} to it prevents non-JSON values such as `Date`, `Map`,
* `Set`, `bigint`, `undefined`, functions or symbols from being advertised as
* structured response data while actually being serialized to (or rejected as)
* strings — which would break the type contract the client relies on.
*/
export type JSONValue =
| null
| boolean
| number
| string
| JSONValue[]
| { [key: string]: JSONValue };

interface TResponseInit extends ResponseInit {
cache?: {
tags?: string[];
Expand Down Expand Up @@ -39,7 +57,10 @@ export class TResponse<T = unknown> extends Response {
this.cache = cache;
}

static override json<T>(data: T, init: TResponseInit = {}): TResponse<T> {
static override json<T extends JSONValue>(
data: T,
init: TResponseInit = {},
): TResponse<T> {
setHeader(init, "Content-Type", "application/json");
const res = new TResponse<T>(JSON.stringify(data), init);
res.data = data;
Expand Down
7 changes: 7 additions & 0 deletions packages/toapi-common/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false
},
"exclude": ["**/*.test.ts"]
}
5 changes: 2 additions & 3 deletions packages/toapi-common/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@
"outDir": "./dist",
"declarationDir": "./dist",
"declaration": true,
"declarationMap": true,
"declarationMap": true
},
"include": ["src"],
"exclude": ["**/*.test.ts"],
"include": ["src"]
}
18 changes: 13 additions & 5 deletions packages/toapi-server/src/api.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,15 @@ export const api = defineApi({
{
authorize: () => true,
},
async (req) =>
TResponse.json(Object.fromEntries(await req.formData()), {
cache: { tags: ["movies"] },
}),
async (req) => {
const data: Record<string, string> = {};
for (const [key, value] of await req.formData()) {
if (typeof value === "string") {
data[key] = value;
}
}
return TResponse.json(data, { cache: { tags: ["movies"] } });
},
),
})
.route("/authorized", {
Expand Down Expand Up @@ -189,7 +194,10 @@ export const api = defineApi({
authorize: () => true,
},
async (req) => {
return TResponse.json(req.query());
const query = req.query();
return TResponse.json(
query.optional === undefined ? {} : query,
);
},
),
});