diff --git a/src/components.ts b/src/components.ts index d32d1b7..422325e 100644 --- a/src/components.ts +++ b/src/components.ts @@ -36,6 +36,7 @@ export namespace Parameters { return (reqCtx).dataAPIEntry?.options ?? {}; } + /** Extracts request filters and mandatory route filters, with route field keys winning. Returns fresh tuples. */ export function ExtractFilters( reqCtx: RequestContext, meta: DataAPIMeta, @@ -51,6 +52,10 @@ export namespace Parameters { : [searchVal, "eq"]; } } + const overrides = GetOptionOverrides(reqCtx); + for (const [key, value] of Object.entries(overrides.filters ?? {})) { + result[key] = [...value]; + } return result; } @@ -75,7 +80,7 @@ export namespace Parameters { const overrides = GetOptionOverrides(reqCtx); const result: Partial = { ...overrides }; for (const key of Object.keys(dynamic)) { - if (!(key in result)) { + if (!(key in result) || dynamic[key] === ExtractFilters) { const extractor = dynamic[key]; if (typeof extractor === "string") { if (extractor.startsWith("multi:")) { @@ -100,6 +105,7 @@ export namespace Parameters { } export interface ListParameters { + /** Mandatory route filters when supplied through WithOptions; combined with request filters by field key. */ filters?: Record; offset?: number; diff --git a/src/index.ts b/src/index.ts index 2f2513a..7d33f81 100644 --- a/src/index.ts +++ b/src/index.ts @@ -178,8 +178,20 @@ function displayOnlyJoinedFields( } export namespace DefaultRoutes { + /** Receives the loaded, unlocked model before readable fields are selected. Throw to deny the read. */ + export type GetGuard = ( + this: unknown, + ctx: RequestContext, + current: Record, + params: Parameters.GetParameters, + ) => void | Promise; + class Methods { - async get(_reqCtx: RequestContext, params: Parameters.GetParameters) { + async get( + reqCtx: RequestContext, + params: Parameters.GetParameters, + guard?: GetGuard, + ) { const meta = GetDataControllerMeta(this); const model = Query.GetModel(this, meta); @@ -195,6 +207,7 @@ export namespace DefaultRoutes { const dbResult = model.constructor.fromDatabase(await query); assert(dbResult, 404, "Not Found"); Validation.Unlock(this, meta, dbResult); + await guard?.call(this, reqCtx, dbResult, params); const results = await Query.ReadProperties(this, meta, dbResult, "get"); @@ -371,6 +384,25 @@ export namespace DefaultRoutes { args: [Context(), Parameters.Get()], method: "get", }; + + /** + * Creates a Get route with an awaited guard after loading and unlocking the + * model, before response projection/transformation. Uses the same query and + * preserves controller `this` and request context. Missing rows remain 404. + * The guard must treat the model as read-only; it includes non-readable fields + * and the normal Get joined/computed/foreign data, not the response DTO. + */ + export function WithGetGuard( + guard: GetGuard, + ): DataControllerCallback { + return { + ...Get, + func: function (ctx, params) { + return Methods.prototype.get.call(this, ctx, params, guard); + }, + }; + } + export const List = { func: Methods.prototype.list, args: [Context(), Parameters.List()], diff --git a/src/tests/components/filter_options.test.ts b/src/tests/components/filter_options.test.ts new file mode 100644 index 0000000..51c84d3 --- /dev/null +++ b/src/tests/components/filter_options.test.ts @@ -0,0 +1,92 @@ +import type { RequestContext } from "@antelopejs/interface-api"; +import { Parameters } from "@antelopejs/interface-data-api/components"; +import type { DataAPIMeta } from "@antelopejs/interface-data-api/metadata"; +import { expect } from "chai"; + +const meta = { + filters: { documentType: {}, status: {} }, +} as unknown as DataAPIMeta; + +function context( + query: string, + options?: Parameters.ListParameters, +): RequestContext { + return { + url: new URL(`http://localhost/?${query}`), + dataAPIEntry: { options }, + } as unknown as RequestContext; +} + +function extract(ctx: RequestContext) { + return Parameters.ExtractGeneric(ctx, meta, { + filters: Parameters.ExtractFilters, + limit: "int", + offset: "int", + sortKey: "string", + }); +} + +describe("Mandatory filter options", () => { + it("combines request filters with mandatory filters, mandatory keys winning", () => { + const ctx = context("filter_status=ne:paid&filter_documentType=quote", { + filters: { documentType: ["invoice", "eq"] }, + }); + const expected = { + documentType: ["invoice", "eq"], + status: ["paid", "ne"], + }; + expect(extract(ctx).filters).to.deep.equal(expected); + expect(Parameters.ExtractFilters(ctx, meta)).to.deep.equal(expected); + }); + + it("keeps request filters with absent, empty or undefined option filters", () => { + for (const options of [ + undefined, + {}, + { filters: {} }, + { filters: undefined }, + ]) { + expect( + extract(context("filter_status=paid", options)).filters, + ).to.deep.equal({ status: ["paid", "eq"] }); + } + expect(extract(context("")).filters).to.deep.equal({}); + }); + + it("preserves ordinary option precedence including explicit undefined", () => { + const result = extract( + context("limit=9&offset=4&sortKey=status", { + limit: 2, + sortKey: undefined, + pluckMode: "select", + }), + ); + expect(result).to.deep.equal({ + filters: {}, + limit: 2, + offset: 4, + sortKey: undefined, + pluckMode: "select", + }); + }); + + it("does not mutate options or leak request filters across requests", () => { + const options: Parameters.ListParameters = { + filters: { documentType: ["invoice", "eq"] }, + }; + Object.freeze(options.filters?.documentType); + Object.freeze(options.filters); + Object.freeze(options); + const first = extract(context("filter_status=paid", options)); + const second = extract(context("filter_status=draft", options)); + expect(first.filters?.status).to.deep.equal(["paid", "eq"]); + expect(second.filters?.status).to.deep.equal(["draft", "eq"]); + expect(first.filters).not.to.equal(second.filters); + if (first.filters) first.filters.documentType[0] = "changed"; + expect(second.filters?.documentType).to.deep.equal(["invoice", "eq"]); + expect(extract(context("", options)).filters).to.deep.equal( + options.filters, + ); + expect(options.filters).to.deep.equal({ documentType: ["invoice", "eq"] }); + }); +}); diff --git a/src/tests/index/get_guard.test.ts b/src/tests/index/get_guard.test.ts new file mode 100644 index 0000000..ee224d1 --- /dev/null +++ b/src/tests/index/get_guard.test.ts @@ -0,0 +1,121 @@ +import { Controller } from "@antelopejs/interface-api"; +import { assert } from "@antelopejs/interface-api-util"; +import { + DataController, + DefaultRoutes, + RegisterDataController, +} from "@antelopejs/interface-data-api"; +import { + Access, + AccessMode, + ModelReference, +} from "@antelopejs/interface-data-api/metadata"; +import { + BasicDataModel, + Field, + Model, + RegisterSchema, + RegisterTable, + Table, +} from "@antelopejs/interface-database-decorators"; +import { expect } from "chai"; +import { getRequest, getSchemaInstance, request } from "../utils"; + +const TABLE = "get-guard-documents"; +const SCHEMA = "default"; +const LOCATION = "get-guard"; +const NOT_FOUND = 404; +const FORBIDDEN = 403; +let calls = 0; +let transforms = 0; + +@RegisterTable(TABLE, SCHEMA) +class Document extends Table { + @Field("string") + declare documentType: string; + @Field("string") + declare name: string; +} +class DocumentModel extends BasicDataModel(Document, TABLE) {} + +const guarded = DefaultRoutes.WithGetGuard( + async function (ctx, current, params) { + await Promise.resolve(); + calls++; + expect(this).to.be.instanceOf(DocumentAPI); + expect(params.id).to.equal(ctx.url.searchParams.get("id")); + assert(current.documentType === "invoice", NOT_FOUND, "Not Found"); + assert(!ctx.url.searchParams.has("deny"), FORBIDDEN, "Denied"); + }, +); + +@RegisterDataController() +class DocumentAPI extends DataController( + Document, + { + get: guarded, + plain: DefaultRoutes.Get, + }, + Controller(`/${LOCATION}`), +) { + @ModelReference() + @Model(DocumentModel) + declare model: DocumentModel; + + @Access(AccessMode.ReadOnly) + get name() { + transforms++; + return this.table.name.toUpperCase(); + } +} + +describe("Get loaded-model guard", () => { + let invoiceId: string; + let quoteId: string; + before(async () => { + await RegisterSchema(SCHEMA); + const model = new DocumentModel(getSchemaInstance(SCHEMA)); + [invoiceId] = await model.insert({ + documentType: "invoice", + name: "Invoice", + }); + [quoteId] = await model.insert({ documentType: "quote", name: "Quote" }); + }); + beforeEach(() => { + calls = 0; + transforms = 0; + }); + + it("reads a non-readable field before transformation and preserves the response", async () => { + const response = await getRequest(LOCATION, { id: invoiceId }); + expect(response.status).to.equal(200); + const body = await response.json(); + expect(body).to.deep.equal({ name: "INVOICE" }); + expect(calls).to.equal(1); + expect(transforms).to.equal(1); + const plain = await request(LOCATION, "plain", "GET", undefined, { + id: invoiceId, + }); + expect(await plain.json()).to.deep.equal(body); + expect(calls).to.equal(1); + }); + + it("awaits rejection before response transformations and preserves status", async () => { + expect((await getRequest(LOCATION, { id: quoteId })).status).to.equal( + NOT_FOUND, + ); + expect( + (await getRequest(LOCATION, { id: invoiceId, deny: "1" })).status, + ).to.equal(FORBIDDEN); + expect(calls).to.equal(2); + expect(transforms).to.equal(0); + }); + + it("does not invoke the guard for missing rows", async () => { + expect( + (await getRequest(LOCATION, { id: "missing-document" })).status, + ).to.equal(NOT_FOUND); + expect(calls).to.equal(0); + expect(transforms).to.equal(0); + }); +});