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
8 changes: 7 additions & 1 deletion src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export namespace Parameters {
return (<any>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,
Expand All @@ -51,6 +52,10 @@ export namespace Parameters {
: [searchVal, "eq"];
}
}
const overrides = GetOptionOverrides<ListParameters>(reqCtx);
for (const [key, value] of Object.entries(overrides.filters ?? {})) {
result[key] = [...value];
}
return result;
}

Expand All @@ -75,7 +80,7 @@ export namespace Parameters {
const overrides = GetOptionOverrides<T>(reqCtx);
const result: Partial<T> = { ...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:")) {
Expand All @@ -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<string, FilterValue>;

offset?: number;
Expand Down
34 changes: 33 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>,
params: Parameters.GetParameters,
) => void | Promise<void>;

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);
Expand All @@ -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");

Expand Down Expand Up @@ -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<Parameters.GetParameters> {
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()],
Expand Down
92 changes: 92 additions & 0 deletions src/tests/components/filter_options.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters.ListParameters>(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"] });
});
});
121 changes: 121 additions & 0 deletions src/tests/index/get_guard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});