mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
Compare commits
2 commits
opencontex
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cfec322ac | ||
|
|
edf9a1b063 |
8 changed files with 159 additions and 36 deletions
|
|
@ -102,6 +102,32 @@ describe("reads", () => {
|
||||||
expect(payload.total).toBeGreaterThan(3);
|
expect(payload.total).toBeGreaterThan(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back safely for negative and malformed pagination values", async () => {
|
||||||
|
const entityResponse = await listEntities(
|
||||||
|
request(`/api/ontologies/${ONTOLOGY}/entities?limit=-1&offset=invalid`),
|
||||||
|
params({ ontologyId: ONTOLOGY })
|
||||||
|
);
|
||||||
|
const entityPayload = await body<{ limit: number; offset: number }>(entityResponse);
|
||||||
|
expect(entityPayload.limit).toBe(50);
|
||||||
|
expect(entityPayload.offset).toBe(0);
|
||||||
|
|
||||||
|
const claimResponse = await listClaims(
|
||||||
|
request(`/api/ontologies/${ONTOLOGY}/claims?limit=1.5`),
|
||||||
|
params({ ontologyId: ONTOLOGY })
|
||||||
|
);
|
||||||
|
expect((await body<{ limit: number }>(claimResponse)).limit).toBe(100);
|
||||||
|
|
||||||
|
const defaultEvents = await events(
|
||||||
|
request(`/api/ontologies/${ONTOLOGY}/events`),
|
||||||
|
params({ ontologyId: ONTOLOGY })
|
||||||
|
);
|
||||||
|
const invalidEvents = await events(
|
||||||
|
request(`/api/ontologies/${ONTOLOGY}/events?limit=-1`),
|
||||||
|
params({ ontologyId: ONTOLOGY })
|
||||||
|
);
|
||||||
|
expect(await body(invalidEvents)).toEqual(await body(defaultEvents));
|
||||||
|
});
|
||||||
|
|
||||||
it("returns ranked matches with evidence when searching", async () => {
|
it("returns ranked matches with evidence when searching", async () => {
|
||||||
const response = await listEntities(
|
const response = await listEntities(
|
||||||
request(`/api/ontologies/${ONTOLOGY}/entities?q=Avery`),
|
request(`/api/ontologies/${ONTOLOGY}/entities?q=Avery`),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { handle, apiJson } from "@/lib/ontology-service";
|
import { handle, apiJson } from "@/lib/ontology-service";
|
||||||
|
import { parseBoundedIntegerParam } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|
@ -6,7 +7,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto
|
||||||
const { ontologyId } = await params;
|
const { ontologyId } = await params;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const statusParam = url.searchParams.get("status") ?? "asserted";
|
const statusParam = url.searchParams.get("status") ?? "asserted";
|
||||||
const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500);
|
const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500);
|
||||||
|
|
||||||
return handle(request, ontologyId, (engine) => {
|
return handle(request, ontologyId, (engine) => {
|
||||||
const claims = engine.store.listClaims({
|
const claims = engine.store.listClaims({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { handle, apiJson } from "@/lib/ontology-service";
|
import { handle, apiJson } from "@/lib/ontology-service";
|
||||||
|
import { parseBoundedIntegerParam } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|
@ -8,8 +9,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const type = url.searchParams.get("type") ?? undefined;
|
const type = url.searchParams.get("type") ?? undefined;
|
||||||
const q = url.searchParams.get("q") ?? undefined;
|
const q = url.searchParams.get("q") ?? undefined;
|
||||||
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
|
const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 50, 1, 200);
|
||||||
const offset = Math.max(Number(url.searchParams.get("offset") ?? 0), 0);
|
const offset = parseBoundedIntegerParam(url.searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
return handle(request, ontologyId, (engine) => {
|
return handle(request, ontologyId, (engine) => {
|
||||||
if (q) {
|
if (q) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getService, apiError, apiJson, engineFor } from "@/lib/ontology-service";
|
import { getService, apiError, apiJson, engineFor } from "@/lib/ontology-service";
|
||||||
|
import { parseBoundedIntegerParam } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|
@ -27,7 +28,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto
|
||||||
const engine = bound.engine;
|
const engine = bound.engine;
|
||||||
|
|
||||||
if (!wantsStream) {
|
if (!wantsStream) {
|
||||||
const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500);
|
const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500);
|
||||||
return apiJson({ events: engine.listEvents({ limit }) });
|
return apiJson({ events: engine.listEvents({ limit }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
17
apps/logicsrc-web/src/lib/pagination.test.ts
Normal file
17
apps/logicsrc-web/src/lib/pagination.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { describe, test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseBoundedIntegerParam } from "./pagination.ts";
|
||||||
|
|
||||||
|
describe("pagination parameters", () => {
|
||||||
|
test("uses the fallback for negative, fractional, and malformed values", () => {
|
||||||
|
for (const value of [null, "-1", "1.5", "invalid", "9007199254740992"]) {
|
||||||
|
assert.equal(parseBoundedIntegerParam(value, 50, 1, 200), 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts valid values and caps the upper bound", () => {
|
||||||
|
assert.equal(parseBoundedIntegerParam(" 1 ", 50, 1, 200), 1);
|
||||||
|
assert.equal(parseBoundedIntegerParam("500", 50, 1, 200), 200);
|
||||||
|
assert.equal(parseBoundedIntegerParam("0", 0, 0, Number.MAX_SAFE_INTEGER), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/logicsrc-web/src/lib/pagination.ts
Normal file
13
apps/logicsrc-web/src/lib/pagination.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export function parseBoundedIntegerParam(
|
||||||
|
value: string | null,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number
|
||||||
|
): number {
|
||||||
|
const text = value?.trim() ?? "";
|
||||||
|
if (!/^\d+$/.test(text)) return fallback;
|
||||||
|
|
||||||
|
const parsed = Number(text);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < minimum) return fallback;
|
||||||
|
return Math.min(parsed, maximum);
|
||||||
|
}
|
||||||
33
packages/opencontext/src/adapters/http.test.ts
Normal file
33
packages/opencontext/src/adapters/http.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { httpAdapter } from "./http.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("http adapter response limits", () => {
|
||||||
|
it("enforces the byte limit for multibyte responses without a content-length header", async () => {
|
||||||
|
const content = "é".repeat(3 * 1024 * 1024);
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(content)));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
httpAdapter.load("https://example.com/context.md", {
|
||||||
|
dir: process.cwd(),
|
||||||
|
offline: false,
|
||||||
|
config: {}
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/response exceeds the 5242880 byte limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes responses within the byte limit", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("café")));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
httpAdapter.load("https://example.com/context.md", {
|
||||||
|
dir: process.cwd(),
|
||||||
|
offline: false,
|
||||||
|
config: {}
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ content: "café", trust: "untrusted" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -20,6 +20,38 @@ export class OfflineError extends Error {
|
||||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
const MAX_BYTES = 5 * 1024 * 1024;
|
const MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
async function readBody(response: Response, uri: string): Promise<string> {
|
||||||
|
if (!response.body) return "";
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
totalBytes += value.byteLength;
|
||||||
|
if (totalBytes > MAX_BYTES) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
throw new Error(`Refusing to load ${uri}: response exceeds the ${MAX_BYTES} byte limit.`);
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(totalBytes);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
export const httpAdapter: Adapter = {
|
export const httpAdapter: Adapter = {
|
||||||
name: "http",
|
name: "http",
|
||||||
schemes: ["http", "https"],
|
schemes: ["http", "https"],
|
||||||
|
|
@ -39,6 +71,7 @@ export const httpAdapter: Adapter = {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
|
|
@ -50,8 +83,6 @@ export const httpAdapter: Adapter = {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to fetch ${uri}: ${(error as Error).message}`);
|
throw new Error(`Failed to fetch ${uri}: ${(error as Error).message}`);
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -63,10 +94,7 @@ export const httpAdapter: Adapter = {
|
||||||
throw new Error(`Refusing to load ${uri}: ${declaredLength} bytes exceeds the ${MAX_BYTES} byte limit.`);
|
throw new Error(`Refusing to load ${uri}: ${declaredLength} bytes exceeds the ${MAX_BYTES} byte limit.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = await response.text();
|
const content = await readBody(response, uri);
|
||||||
if (content.length > MAX_BYTES) {
|
|
||||||
throw new Error(`Refusing to load ${uri}: response exceeds the ${MAX_BYTES} byte limit.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content,
|
content,
|
||||||
|
|
@ -75,5 +103,8 @@ export const httpAdapter: Adapter = {
|
||||||
retrievedAt: new Date().toISOString(),
|
retrievedAt: new Date().toISOString(),
|
||||||
trust: (ctx.config.trust as AdapterResult["trust"]) ?? "untrusted"
|
trust: (ctx.config.trust as AdapterResult["trust"]) ?? "untrusted"
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue