@schmock/query 2.2.3 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Khalic Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @schmock/query
2
+
3
+ Pagination, sorting, and filtering for Schmock list endpoints, driven by query parameters.
4
+
5
+ Part of [Schmock](https://github.com/khalic-lab/schmock) — mock APIs from OpenAPI specs or hand-crafted routes.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add -d @schmock/query
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```typescript
16
+ import { schmock } from "@schmock/core";
17
+ import { queryPlugin } from "@schmock/query";
18
+
19
+ const mock = schmock();
20
+
21
+ mock("GET /users", users).pipe(
22
+ queryPlugin({
23
+ pagination: { defaultLimit: 10, maxLimit: 100 },
24
+ sorting: { allowed: ["name"] },
25
+ }),
26
+ );
27
+
28
+ // Through an adapter: GET /users?page=2&limit=10&sort=name
29
+ const response = await mock.handle("GET", "/users", {
30
+ query: { page: "2", limit: "10", sort: "name" },
31
+ });
32
+ ```
33
+
34
+ ## Documentation
35
+
36
+ - [API reference](https://github.com/khalic-lab/schmock/blob/main/docs/api.md)
37
+ - [Plugin development](https://github.com/khalic-lab/schmock/blob/main/docs/plugins.md)
38
+
39
+ ## License
40
+
41
+ MIT © Khalic Lab
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- interface PaginationOptions {
1
+ import type * as Schmock from "@schmock/core";
2
+ export interface PaginationOptions {
2
3
  /** Default items per page (default: 10) */
3
4
  defaultLimit?: number;
4
5
  /** Maximum items per page (default: 100) */
@@ -8,7 +9,7 @@ interface PaginationOptions {
8
9
  /** Query parameter name for limit (default: "limit") */
9
10
  limitParam?: string;
10
11
  }
11
- interface SortingOptions {
12
+ export interface SortingOptions {
12
13
  /** Fields allowed for sorting */
13
14
  allowed: string[];
14
15
  /** Default sort field */
@@ -20,17 +21,25 @@ interface SortingOptions {
20
21
  /** Query parameter name for sort order (default: "order") */
21
22
  orderParam?: string;
22
23
  }
23
- interface FilteringOptions {
24
+ export interface FilteringOptions {
24
25
  /** Fields allowed for filtering */
25
26
  allowed: string[];
26
27
  /** Query parameter prefix for filters (default: "filter") */
27
28
  filterPrefix?: string;
28
29
  }
29
- interface QueryPluginOptions {
30
+ export interface QueryPluginOptions {
30
31
  pagination?: PaginationOptions;
31
32
  sorting?: SortingOptions;
32
33
  filtering?: FilteringOptions;
33
34
  }
34
- export declare function queryPlugin(options: QueryPluginOptions): Schmock.Plugin;
35
- export {};
35
+ export declare function queryPlugin(options?: QueryPluginOptions): Schmock.Plugin;
36
+ export interface PaginatedResult {
37
+ data: unknown[];
38
+ pagination: {
39
+ page: number;
40
+ limit: number;
41
+ total: number;
42
+ totalPages: number;
43
+ };
44
+ }
36
45
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,UAAU,iBAAiB;IACzB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,cAAc;IACtB,iCAAiC;IACjC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0CAA0C;IAC1C,YAAY,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC9B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,gBAAgB;IACxB,mCAAmC;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,kBAAkB;IAC1B,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAoCvE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,OAAO,MAAM,eAAe,CAAC;AAI9C,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,iCAAiC;IACjC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0CAA0C;IAC1C,YAAY,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC9B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,mCAAmC;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AA6LD,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,MAAM,CA2C5E;AA4JD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,UAAU,EAAE;QACV,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;CACH"}
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- var B="2.2.3";function N(c){return{name:"query",version:B,process(h,d){if(!Array.isArray(d))return{context:h,response:d};let j=[...d],k=h.query||{};if(c.filtering)j=E(j,k,c.filtering);if(c.sorting)j=G(j,k,c.sorting);if(c.pagination){let v=H(j,k,c.pagination);return{context:h,response:v}}return{context:h,response:j}}}}function E(c,h,d){let j=d.filterPrefix??"filter",k=c;for(let v of d.allowed){let g=`${j}[${v}]`,f=`${j}.${v}`,b=h[g]??h[f]??h[v];if(b!==void 0)k=k.filter((w)=>{if(typeof w!=="object"||w===null)return!1;let D=w[v];if(D===void 0)return!1;return String(D)===b})}return k}function G(c,h,d){let j=d.sortParam??"sort",k=d.orderParam??"order",v=h[j]??d.default,f=(h[k]??d.defaultOrder??"asc")==="desc"?"desc":"asc";if(!v)return c;if(!d.allowed.includes(v))return c;return c.sort((b,w)=>{if(typeof b!=="object"||b===null)return 0;if(typeof w!=="object"||w===null)return 0;let z=b,D=w,M=z[v],T=D[v];if(M===T)return 0;if(M===void 0)return 1;if(T===void 0)return-1;let A;if(typeof M==="number"&&typeof T==="number")A=M-T;else A=String(M).localeCompare(String(T));return f==="desc"?-A:A})}function H(c,h,d){let j=d.pageParam??"page",k=d.limitParam??"limit",v=d.defaultLimit??10,g=d.maxLimit??100,f=Math.max(1,Number.parseInt(h[j]||"1",10)||1),b=Math.min(g,Math.max(1,Number.parseInt(h[k]||String(v),10)||v)),w=c.length,z=Math.ceil(w/b),D=(f-1)*b;return{data:c.slice(D,D+b),pagination:{page:f,limit:b,total:w,totalPages:z}}}export{N as queryPlugin};
1
+ import{isStatusTuple as L,SchmockError as F}from"@schmock/core";var _="2.3.1";var c=new Set(["__proto__","constructor","prototype"]);function T(h){return typeof h==="object"&&h!==null}function W(h,j){try{return Object.hasOwn(h,j)?h[j]:void 0}catch{return}}function O(h){try{return typeof h==="string"?JSON.stringify(h):String(h)}catch{return"<unprintable>"}}function Z(h,j,M){return new F(`queryPlugin: ${h} (received ${O(M)})`,"QUERY_CONFIG_INVALID",{option:j,received:M})}function I(h,j){if(h===void 0)return;if(typeof h!=="number"||!Number.isSafeInteger(h)||h<1)throw Z(`${j} must be a positive integer`,j,h)}function Y(h,j){if(h===void 0)return;if(typeof h!=="string"||h.length===0)throw Z(`${j} must be a non-empty string`,j,h)}function K(h,j){if(!Array.isArray(h))throw Z(`${j} must be an array of field names`,j,h);for(let M of h){if(typeof M!=="string"||M.length===0)throw Z(`${j} must contain only non-empty field names`,j,M);if(c.has(M))throw Z(`${j} must not contain the reserved field name`,j,M)}}function V(h){let{pagination:j,sorting:M,filtering:z}=h;if(j)I(j.defaultLimit,"pagination.defaultLimit"),I(j.maxLimit,"pagination.maxLimit"),Y(j.pageParam,"pagination.pageParam"),Y(j.limitParam,"pagination.limitParam");if(M)K(M.allowed,"sorting.allowed"),Y(M.sortParam,"sorting.sortParam"),Y(M.orderParam,"sorting.orderParam");if(z)K(z.allowed,"filtering.allowed"),Y(z.filterPrefix,"filtering.filterPrefix")}function q(h){let{pagination:j,sorting:M,filtering:z}=h,D,H,Q;if(j){let{defaultLimit:J,maxLimit:G,pageParam:U,limitParam:X}=j;D={defaultLimit:J,maxLimit:G,pageParam:U,limitParam:X}}if(M){let{allowed:J,default:G,defaultOrder:U,sortParam:X,orderParam:$}=M;H={allowed:Array.isArray(J)?[...J]:J,default:G,defaultOrder:U,sortParam:X,orderParam:$}}if(z){let{allowed:J,filterPrefix:G}=z;Q={allowed:Array.isArray(J)?[...J]:J,filterPrefix:G}}return{pagination:D,sorting:H,filtering:Q}}function P(h){return T(h)&&!Array.isArray(h)&&Object.values(h).every((j)=>typeof j==="string")}function N(h){return T(h)&&!Array.isArray(h)&&typeof h.status==="number"&&"body"in h&&(h.headers===void 0||P(h.headers))}function E(h){if(L(h))return h[1];if(N(h))return h.body;return h}function k(h,j){if(L(h))return h.length===3?[h[0],j,h[2]]:[h[0],j];if(N(h))return h.headers===void 0?{status:h.status,body:j}:{status:h.status,body:j,headers:h.headers};return j}function u(h={}){let j=q(h);return V(j),{name:"query",version:_,process(M,z){let D=E(z);if(!Array.isArray(D))return{context:M,response:z};let H=[...D],Q=M.query||{};if(j.filtering)H=R(H,Q,j.filtering);if(j.sorting)H=b(H,Q,j.sorting);if(j.pagination){let J=d(H,Q,j.pagination);return{context:M,response:k(z,J)}}return{context:M,response:k(z,H)}}}}function R(h,j,M){let z=M.filterPrefix??"filter",D=h;for(let H of M.allowed){let Q=W(j,`${z}[${H}]`)??W(j,`${z}.${H}`);if(Q!==void 0)D=D.filter((J)=>{if(!T(J))return!1;let G=W(J,H);if(G===void 0)return!1;try{return String(G)===Q}catch{return!1}})}return D}function w(h,j){if(h<j)return-1;return h>j?1:0}function S(h){if(h===void 0)return{kind:"missing"};if(h===null)return{kind:"null"};if(typeof h==="number")return Number.isFinite(h)?{kind:"number",value:h}:{kind:"non-finite-number",value:h};if(typeof h==="string")return{kind:"string",value:h};if(typeof h==="boolean")return{kind:"boolean",value:h};if(typeof h==="bigint")return{kind:"bigint",value:h};return{kind:"unsupported"}}function A(h){switch(h.kind){case"number":return 0;case"non-finite-number":return 1;case"string":return 2;case"boolean":return 3;case"bigint":return 4;case"null":return 5;case"unsupported":return 6;case"missing":return 7}}function C(h){if(h===Number.NEGATIVE_INFINITY)return 0;if(h===Number.POSITIVE_INFINITY)return 1;return 2}function f(h,j,M){if(h.kind==="missing"||j.kind==="missing"){if(h.kind===j.kind)return 0;return h.kind==="missing"?1:-1}if(h.kind==="unsupported"||j.kind==="unsupported"){if(h.kind===j.kind)return 0;return h.kind==="unsupported"?1:-1}let z=A(h)-A(j),D=z;if(z===0){if(h.kind==="number"&&j.kind==="number")D=h.value<j.value?-1:h.value>j.value?1:0;else if(h.kind==="non-finite-number"&&j.kind==="non-finite-number")D=C(h.value)-C(j.value);else if(h.kind==="string"&&j.kind==="string")D=w(h.value,j.value);else if(h.kind==="boolean"&&j.kind==="boolean")D=Number(h.value)-Number(j.value);else if(h.kind==="bigint"&&j.kind==="bigint")D=h.value<j.value?-1:h.value>j.value?1:0}return M==="desc"?-D:D}function b(h,j,M){let z=M.sortParam??"sort",D=M.orderParam??"order",H=W(j,z)??M.default,J=(W(j,D)??M.defaultOrder??"asc")==="desc"?"desc":"asc";if(!H)return h;if(!M.allowed.includes(H))return h;return h.map((G)=>({item:G,key:S(T(G)?W(G,H):void 0)})).sort((G,U)=>f(G.key,U.key,J)).map(({item:G})=>G)}function B(h,j){if(typeof h!=="string"||!/^[0-9]+$/.test(h))return j;let M=Number(h);return Number.isSafeInteger(M)&&M>=1?M:j}function d(h,j,M){let z=M.pageParam??"page",D=M.limitParam??"limit",H=M.defaultLimit??10,Q=M.maxLimit??100,J=B(W(j,z),1),G=Math.min(Q,B(W(j,D),H)),U=h.length,X=Math.ceil(U/G),$=(J-1)*G;return{data:h.slice($,$+G),pagination:{page:J,limit:G,total:U,totalPages:X}}}export{u as queryPlugin};
package/package.json CHANGED
@@ -1,13 +1,21 @@
1
1
  {
2
2
  "name": "@schmock/query",
3
3
  "description": "Pagination, filtering, and sorting plugin for Schmock",
4
- "version": "2.2.3",
4
+ "version": "2.3.1",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/khalic-lab/schmock.git",
9
+ "directory": "packages/query"
10
+ },
11
+ "homepage": "https://github.com/khalic-lab/schmock/tree/main/packages/query#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/khalic-lab/schmock/issues"
14
+ },
6
15
  "main": "./dist/index.js",
7
16
  "types": "./dist/index.d.ts",
8
17
  "files": [
9
- "dist",
10
- "src"
18
+ "dist"
11
19
  ],
12
20
  "exports": {
13
21
  ".": {
@@ -18,10 +26,11 @@
18
26
  "./package.json": "./package.json"
19
27
  },
20
28
  "scripts": {
21
- "build": "bun build:lib && bun build:types",
22
- "build:lib": "bun build --minify --target node --outdir=dist src/index.ts",
29
+ "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo",
30
+ "build": "bun run clean && bun run build:lib && bun run build:types",
31
+ "build:lib": "bun build --minify --target node --format esm --outdir=dist src/index.ts --external @schmock/core",
23
32
  "build:types": "tsc -p tsconfig.json",
24
- "test": "vitest",
33
+ "test": "vitest run",
25
34
  "test:watch": "vitest --watch",
26
35
  "test:bdd": "vitest run --config vitest.config.bdd.ts",
27
36
  "lint": "biome check src/*.ts",
@@ -30,7 +39,7 @@
30
39
  },
31
40
  "license": "MIT",
32
41
  "peerDependencies": {
33
- "@schmock/core": "^2.2.3"
42
+ "@schmock/core": "^2.3.1"
34
43
  },
35
44
  "devDependencies": {
36
45
  "@amiceli/vitest-cucumber": "^7.0.0",
package/src/index.test.ts DELETED
@@ -1,465 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { version as packageVersion } from "../package.json";
3
- import { queryPlugin } from "./index";
4
-
5
- describe("queryPlugin", () => {
6
- it("creates a plugin with correct name", () => {
7
- const plugin = queryPlugin({ pagination: {} });
8
- expect(plugin.name).toBe("query");
9
- expect(plugin.version).toBe(packageVersion);
10
- });
11
-
12
- it("passes through non-array responses", async () => {
13
- const plugin = queryPlugin({ pagination: {} });
14
- const result = await plugin.process(
15
- {
16
- path: "/test",
17
- route: {},
18
- method: "GET",
19
- params: {},
20
- query: {},
21
- headers: {},
22
- state: new Map(),
23
- },
24
- { message: "not an array" },
25
- );
26
-
27
- expect(result.response).toEqual({ message: "not an array" });
28
- });
29
-
30
- it("paginates array responses", async () => {
31
- const plugin = queryPlugin({
32
- pagination: { defaultLimit: 2, maxLimit: 10 },
33
- });
34
-
35
- const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
36
- const result = await plugin.process(
37
- {
38
- path: "/test",
39
- route: {},
40
- method: "GET",
41
- params: {},
42
- query: { page: "2", limit: "2" },
43
- headers: {},
44
- state: new Map(),
45
- },
46
- items,
47
- );
48
-
49
- expect(result.response).toEqual({
50
- data: [{ id: 3 }, { id: 4 }],
51
- pagination: { page: 2, limit: 2, total: 5, totalPages: 3 },
52
- });
53
- });
54
-
55
- it("sorts array responses", async () => {
56
- const plugin = queryPlugin({
57
- sorting: { allowed: ["name"], default: "name" },
58
- });
59
-
60
- const items = [
61
- { id: 3, name: "Charlie" },
62
- { id: 1, name: "Alice" },
63
- { id: 2, name: "Bob" },
64
- ];
65
-
66
- const result = await plugin.process(
67
- {
68
- path: "/test",
69
- route: {},
70
- method: "GET",
71
- params: {},
72
- query: { sort: "name", order: "asc" },
73
- headers: {},
74
- state: new Map(),
75
- },
76
- items,
77
- );
78
-
79
- expect(result.response.map((i: any) => i.name)).toEqual([
80
- "Alice",
81
- "Bob",
82
- "Charlie",
83
- ]);
84
- });
85
-
86
- it("filters array responses", async () => {
87
- const plugin = queryPlugin({
88
- filtering: { allowed: ["role"] },
89
- });
90
-
91
- const items = [
92
- { id: 1, name: "Alice", role: "admin" },
93
- { id: 2, name: "Bob", role: "user" },
94
- { id: 3, name: "Charlie", role: "admin" },
95
- ];
96
-
97
- const result = await plugin.process(
98
- {
99
- path: "/test",
100
- route: {},
101
- method: "GET",
102
- params: {},
103
- query: { "filter[role]": "admin" },
104
- headers: {},
105
- state: new Map(),
106
- },
107
- items,
108
- );
109
-
110
- expect(result.response).toHaveLength(2);
111
- expect(result.response.every((i: any) => i.role === "admin")).toBe(true);
112
- });
113
-
114
- it("respects maxLimit", async () => {
115
- const plugin = queryPlugin({
116
- pagination: { defaultLimit: 5, maxLimit: 3 },
117
- });
118
-
119
- const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
120
- const result = await plugin.process(
121
- {
122
- path: "/test",
123
- route: {},
124
- method: "GET",
125
- params: {},
126
- query: { limit: "100" },
127
- headers: {},
128
- state: new Map(),
129
- },
130
- items,
131
- );
132
-
133
- expect(result.response.data).toHaveLength(3);
134
- });
135
-
136
- describe("filtering edge cases", () => {
137
- it("returns empty result when input array is empty", async () => {
138
- const plugin = queryPlugin({
139
- filtering: { allowed: ["role"] },
140
- });
141
-
142
- const result = await plugin.process(
143
- {
144
- path: "/test",
145
- route: {},
146
- method: "GET",
147
- params: {},
148
- query: { "filter[role]": "admin" },
149
- headers: {},
150
- state: new Map(),
151
- },
152
- [],
153
- );
154
-
155
- expect(result.response).toEqual([]);
156
- });
157
-
158
- it("excludes items that lack the filter field", async () => {
159
- const plugin = queryPlugin({
160
- filtering: { allowed: ["role"] },
161
- });
162
-
163
- const items = [
164
- { id: 1, role: "admin" },
165
- { id: 2, name: "no-role" },
166
- { id: 3, role: "admin" },
167
- ];
168
-
169
- const result = await plugin.process(
170
- {
171
- path: "/test",
172
- route: {},
173
- method: "GET",
174
- params: {},
175
- query: { "filter[role]": "admin" },
176
- headers: {},
177
- state: new Map(),
178
- },
179
- items,
180
- );
181
-
182
- expect(result.response).toHaveLength(2);
183
- expect(result.response.every((i: any) => i.role === "admin")).toBe(true);
184
- });
185
-
186
- it("handles filter value with special regex characters as literal string", async () => {
187
- const plugin = queryPlugin({
188
- filtering: { allowed: ["domain"] },
189
- });
190
-
191
- const items = [
192
- { id: 1, domain: "foo.bar" },
193
- { id: 2, domain: "fooXbar" },
194
- { id: 3, domain: "foo.bar" },
195
- ];
196
-
197
- const result = await plugin.process(
198
- {
199
- path: "/test",
200
- route: {},
201
- method: "GET",
202
- params: {},
203
- query: { "filter[domain]": "foo.bar" },
204
- headers: {},
205
- state: new Map(),
206
- },
207
- items,
208
- );
209
-
210
- expect(result.response).toHaveLength(2);
211
- expect(result.response.every((i: any) => i.domain === "foo.bar")).toBe(
212
- true,
213
- );
214
- });
215
- });
216
-
217
- describe("sorting edge cases", () => {
218
- it("sorts with mixed numeric and string values in the sort field", async () => {
219
- const plugin = queryPlugin({
220
- sorting: { allowed: ["score"], default: "score" },
221
- });
222
-
223
- const items = [
224
- { id: 1, score: 10 },
225
- { id: 2, score: "5" },
226
- { id: 3, score: 2 },
227
- ];
228
-
229
- const result = await plugin.process(
230
- {
231
- path: "/test",
232
- route: {},
233
- method: "GET",
234
- params: {},
235
- query: { sort: "score", order: "asc" },
236
- headers: {},
237
- state: new Map(),
238
- },
239
- items,
240
- );
241
-
242
- // Mixed types fall back to localeCompare via String() coercion
243
- expect(result.response).toHaveLength(3);
244
- });
245
-
246
- it("pushes items with undefined sort field values to the end", async () => {
247
- const plugin = queryPlugin({
248
- sorting: { allowed: ["name"], default: "name" },
249
- });
250
-
251
- const items = [
252
- { id: 1 },
253
- { id: 2, name: "Alice" },
254
- { id: 3, name: "Bob" },
255
- ];
256
-
257
- const result = await plugin.process(
258
- {
259
- path: "/test",
260
- route: {},
261
- method: "GET",
262
- params: {},
263
- query: { sort: "name", order: "asc" },
264
- headers: {},
265
- state: new Map(),
266
- },
267
- items,
268
- );
269
-
270
- expect(result.response[0].name).toBe("Alice");
271
- expect(result.response[1].name).toBe("Bob");
272
- expect(result.response[2].name).toBeUndefined();
273
- });
274
-
275
- it("maintains relative order for items with equal sort values", async () => {
276
- const plugin = queryPlugin({
277
- sorting: { allowed: ["priority"], default: "priority" },
278
- });
279
-
280
- const items = [
281
- { id: 1, priority: 1, label: "first" },
282
- { id: 2, priority: 1, label: "second" },
283
- { id: 3, priority: 1, label: "third" },
284
- ];
285
-
286
- const result = await plugin.process(
287
- {
288
- path: "/test",
289
- route: {},
290
- method: "GET",
291
- params: {},
292
- query: { sort: "priority", order: "asc" },
293
- headers: {},
294
- state: new Map(),
295
- },
296
- items,
297
- );
298
-
299
- // Sort stability: equal values preserve insertion order
300
- expect(result.response.map((i: any) => i.label)).toEqual([
301
- "first",
302
- "second",
303
- "third",
304
- ]);
305
- });
306
- });
307
-
308
- describe("pagination edge cases", () => {
309
- const makePaginationPlugin = (defaultLimit = 10, maxLimit = 100) =>
310
- queryPlugin({
311
- pagination: { defaultLimit, maxLimit },
312
- });
313
-
314
- const items = Array.from({ length: 5 }, (_, i) => ({ id: i + 1 }));
315
-
316
- const makeContext = (query: Record<string, string>) => ({
317
- path: "/test",
318
- route: {},
319
- method: "GET" as const,
320
- params: {},
321
- query,
322
- headers: {},
323
- state: new Map(),
324
- });
325
-
326
- it("clamps page=0 to page 1", async () => {
327
- const plugin = makePaginationPlugin(2);
328
- const result = await plugin.process(makeContext({ page: "0" }), items);
329
-
330
- expect(result.response.pagination.page).toBe(1);
331
- expect(result.response.data).toEqual([{ id: 1 }, { id: 2 }]);
332
- });
333
-
334
- it("clamps page=-1 to page 1", async () => {
335
- const plugin = makePaginationPlugin(2);
336
- const result = await plugin.process(makeContext({ page: "-1" }), items);
337
-
338
- expect(result.response.pagination.page).toBe(1);
339
- expect(result.response.data).toEqual([{ id: 1 }, { id: 2 }]);
340
- });
341
-
342
- it("clamps limit=0 to defaultLimit (0 is falsy, falls back to default)", async () => {
343
- const plugin = makePaginationPlugin(10, 100);
344
- const result = await plugin.process(makeContext({ limit: "0" }), items);
345
-
346
- // parseInt("0") is 0, which is falsy → falls back to defaultLimit (10)
347
- expect(result.response.pagination.limit).toBe(10);
348
- expect(result.response.data).toHaveLength(5);
349
- });
350
-
351
- it("clamps limit exceeding maxLimit to maxLimit", async () => {
352
- const plugin = makePaginationPlugin(10, 3);
353
- const result = await plugin.process(makeContext({ limit: "999" }), items);
354
-
355
- expect(result.response.pagination.limit).toBe(3);
356
- expect(result.response.data).toHaveLength(3);
357
- });
358
-
359
- it("defaults gracefully when page is non-numeric", async () => {
360
- const plugin = makePaginationPlugin(2);
361
- const result = await plugin.process(makeContext({ page: "abc" }), items);
362
-
363
- expect(result.response.pagination.page).toBe(1);
364
- expect(result.response.data).toEqual([{ id: 1 }, { id: 2 }]);
365
- });
366
-
367
- it("defaults gracefully when limit is non-numeric", async () => {
368
- const plugin = makePaginationPlugin(2);
369
- const result = await plugin.process(makeContext({ limit: "abc" }), items);
370
-
371
- expect(result.response.pagination.limit).toBe(2);
372
- expect(result.response.data).toEqual([{ id: 1 }, { id: 2 }]);
373
- });
374
-
375
- it("returns empty data with correct total for a very large page number", async () => {
376
- const plugin = makePaginationPlugin(2);
377
- const result = await plugin.process(makeContext({ page: "9999" }), items);
378
-
379
- expect(result.response.data).toEqual([]);
380
- expect(result.response.pagination.total).toBe(5);
381
- expect(result.response.pagination.totalPages).toBe(3);
382
- });
383
-
384
- it("returns data=[], total=0, totalPages=0 for empty array input", async () => {
385
- const plugin = makePaginationPlugin(10);
386
- const result = await plugin.process(makeContext({}), []);
387
-
388
- expect(result.response.data).toEqual([]);
389
- expect(result.response.pagination.total).toBe(0);
390
- expect(result.response.pagination.totalPages).toBe(0);
391
- });
392
- });
393
-
394
- describe("process method edge cases", () => {
395
- it("passes through null response unchanged", async () => {
396
- const plugin = queryPlugin({
397
- pagination: { defaultLimit: 10 },
398
- filtering: { allowed: ["role"] },
399
- sorting: { allowed: ["name"] },
400
- });
401
-
402
- const result = await plugin.process(
403
- {
404
- path: "/test",
405
- route: {},
406
- method: "GET",
407
- params: {},
408
- query: {},
409
- headers: {},
410
- state: new Map(),
411
- },
412
- null,
413
- );
414
-
415
- expect(result.response).toBeNull();
416
- });
417
-
418
- it("passes through object (non-array) response unchanged", async () => {
419
- const plugin = queryPlugin({
420
- pagination: { defaultLimit: 10 },
421
- filtering: { allowed: ["role"] },
422
- sorting: { allowed: ["name"] },
423
- });
424
-
425
- const obj = { message: "hello", count: 42 };
426
- const result = await plugin.process(
427
- {
428
- path: "/test",
429
- route: {},
430
- method: "GET",
431
- params: {},
432
- query: {},
433
- headers: {},
434
- state: new Map(),
435
- },
436
- obj,
437
- );
438
-
439
- expect(result.response).toEqual(obj);
440
- });
441
-
442
- it("passes through undefined response unchanged", async () => {
443
- const plugin = queryPlugin({
444
- pagination: { defaultLimit: 10 },
445
- filtering: { allowed: ["role"] },
446
- sorting: { allowed: ["name"] },
447
- });
448
-
449
- const result = await plugin.process(
450
- {
451
- path: "/test",
452
- route: {},
453
- method: "GET",
454
- params: {},
455
- query: {},
456
- headers: {},
457
- state: new Map(),
458
- },
459
- undefined,
460
- );
461
-
462
- expect(result.response).toBeUndefined();
463
- });
464
- });
465
- });
package/src/index.ts DELETED
@@ -1,194 +0,0 @@
1
- /// <reference path="../../core/schmock.d.ts" />
2
-
3
- import { version as packageVersion } from "../package.json";
4
-
5
- interface PaginationOptions {
6
- /** Default items per page (default: 10) */
7
- defaultLimit?: number;
8
- /** Maximum items per page (default: 100) */
9
- maxLimit?: number;
10
- /** Query parameter name for page number (default: "page") */
11
- pageParam?: string;
12
- /** Query parameter name for limit (default: "limit") */
13
- limitParam?: string;
14
- }
15
-
16
- interface SortingOptions {
17
- /** Fields allowed for sorting */
18
- allowed: string[];
19
- /** Default sort field */
20
- default?: string;
21
- /** Default sort order (default: "asc") */
22
- defaultOrder?: "asc" | "desc";
23
- /** Query parameter name for sort field (default: "sort") */
24
- sortParam?: string;
25
- /** Query parameter name for sort order (default: "order") */
26
- orderParam?: string;
27
- }
28
-
29
- interface FilteringOptions {
30
- /** Fields allowed for filtering */
31
- allowed: string[];
32
- /** Query parameter prefix for filters (default: "filter") */
33
- filterPrefix?: string;
34
- }
35
-
36
- interface QueryPluginOptions {
37
- pagination?: PaginationOptions;
38
- sorting?: SortingOptions;
39
- filtering?: FilteringOptions;
40
- }
41
-
42
- export function queryPlugin(options: QueryPluginOptions): Schmock.Plugin {
43
- return {
44
- name: "query",
45
- version: packageVersion,
46
-
47
- process(
48
- context: Schmock.PluginContext,
49
- response?: unknown,
50
- ): Schmock.PluginResult {
51
- // Only process array responses
52
- if (!Array.isArray(response)) {
53
- return { context, response };
54
- }
55
-
56
- let items: unknown[] = [...response];
57
- const query = context.query || {};
58
-
59
- // Apply filtering
60
- if (options.filtering) {
61
- items = applyFiltering(items, query, options.filtering);
62
- }
63
-
64
- // Apply sorting
65
- if (options.sorting) {
66
- items = applySorting(items, query, options.sorting);
67
- }
68
-
69
- // Apply pagination
70
- if (options.pagination) {
71
- const result = applyPagination(items, query, options.pagination);
72
- return { context, response: result };
73
- }
74
-
75
- return { context, response: items };
76
- },
77
- };
78
- }
79
-
80
- function applyFiltering(
81
- items: unknown[],
82
- query: Record<string, string>,
83
- options: FilteringOptions,
84
- ): unknown[] {
85
- const prefix = options.filterPrefix ?? "filter";
86
- let result = items;
87
-
88
- for (const field of options.allowed) {
89
- // Support filter[field]=value format
90
- const bracketKey = `${prefix}[${field}]`;
91
- // Support filter.field=value format
92
- const dotKey = `${prefix}.${field}`;
93
- // Support plain field=value format as fallback
94
- const value = query[bracketKey] ?? query[dotKey] ?? query[field];
95
-
96
- if (value !== undefined) {
97
- result = result.filter((item) => {
98
- if (typeof item !== "object" || item === null) return false;
99
- const record = item as Record<string, unknown>;
100
- const itemValue = record[field];
101
- if (itemValue === undefined) return false;
102
- // Intentional string coercion: query params are inherently strings
103
- return String(itemValue) === value;
104
- });
105
- }
106
- }
107
-
108
- return result;
109
- }
110
-
111
- function applySorting(
112
- items: unknown[],
113
- query: Record<string, string>,
114
- options: SortingOptions,
115
- ): unknown[] {
116
- const sortParam = options.sortParam ?? "sort";
117
- const orderParam = options.orderParam ?? "order";
118
- const sortField = query[sortParam] ?? options.default;
119
- const rawOrder = query[orderParam] ?? options.defaultOrder ?? "asc";
120
- const sortOrder = rawOrder === "desc" ? "desc" : "asc";
121
-
122
- if (!sortField) return items;
123
-
124
- // Only sort by allowed fields
125
- if (!options.allowed.includes(sortField)) return items;
126
-
127
- return items.sort((a, b) => {
128
- if (typeof a !== "object" || a === null) return 0;
129
- if (typeof b !== "object" || b === null) return 0;
130
- const aRecord = a as Record<string, unknown>;
131
- const bRecord = b as Record<string, unknown>;
132
- const aVal = aRecord[sortField];
133
- const bVal = bRecord[sortField];
134
-
135
- if (aVal === bVal) return 0;
136
- if (aVal === undefined) return 1;
137
- if (bVal === undefined) return -1;
138
-
139
- let comparison: number;
140
- if (typeof aVal === "number" && typeof bVal === "number") {
141
- comparison = aVal - bVal;
142
- } else {
143
- comparison = String(aVal).localeCompare(String(bVal));
144
- }
145
-
146
- return sortOrder === "desc" ? -comparison : comparison;
147
- });
148
- }
149
-
150
- interface PaginatedResult {
151
- data: unknown[];
152
- pagination: {
153
- page: number;
154
- limit: number;
155
- total: number;
156
- totalPages: number;
157
- };
158
- }
159
-
160
- function applyPagination(
161
- items: unknown[],
162
- query: Record<string, string>,
163
- options: PaginationOptions,
164
- ): PaginatedResult {
165
- const pageParam = options.pageParam ?? "page";
166
- const limitParam = options.limitParam ?? "limit";
167
- const defaultLimit = options.defaultLimit ?? 10;
168
- const maxLimit = options.maxLimit ?? 100;
169
-
170
- const page = Math.max(1, Number.parseInt(query[pageParam] || "1", 10) || 1);
171
- const limit = Math.min(
172
- maxLimit,
173
- Math.max(
174
- 1,
175
- Number.parseInt(query[limitParam] || String(defaultLimit), 10) ||
176
- defaultLimit,
177
- ),
178
- );
179
-
180
- const total = items.length;
181
- const totalPages = Math.ceil(total / limit);
182
- const start = (page - 1) * limit;
183
- const data = items.slice(start, start + limit);
184
-
185
- return {
186
- data,
187
- pagination: {
188
- page,
189
- limit,
190
- total,
191
- totalPages,
192
- },
193
- };
194
- }
@@ -1,217 +0,0 @@
1
- import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
2
- import { schmock } from "@schmock/core";
3
- import { expect } from "vitest";
4
- import { queryPlugin } from "../index";
5
-
6
- const feature = await loadFeature("../../features/query-plugin.feature");
7
-
8
- describeFeature(feature, ({ Scenario }) => {
9
- let mock: any;
10
- let response: any;
11
-
12
- // Generate test data
13
- const generateItems = (count: number) =>
14
- Array.from({ length: count }, (_, i) => ({ id: i + 1, name: `Item ${i + 1}` }));
15
-
16
- const namedItems = [
17
- { id: 1, name: "Charlie" },
18
- { id: 2, name: "Alice" },
19
- { id: 3, name: "Bob" },
20
- ];
21
-
22
- const categorizedItems = [
23
- { id: 1, name: "Alice", role: "admin" },
24
- { id: 2, name: "Bob", role: "user" },
25
- { id: 3, name: "Charlie", role: "admin" },
26
- { id: 4, name: "Dave", role: "user" },
27
- { id: 5, name: "Eve", role: "moderator" },
28
- ];
29
-
30
- Scenario("Basic pagination with default settings", ({ Given, When, Then, And }) => {
31
- Given("I create a mock with 25 items and pagination plugin", () => {
32
- mock = schmock();
33
- mock("GET /items", generateItems(25))
34
- .pipe(queryPlugin({
35
- pagination: { defaultLimit: 10, maxLimit: 100 },
36
- }));
37
- });
38
-
39
- When("I request page 1", async () => {
40
- response = await mock.handle("GET", "/items", {
41
- query: { page: "1" },
42
- });
43
- });
44
-
45
- Then("I should receive {int} items", (_, count: number) => {
46
- expect(response.body.data).toHaveLength(count);
47
- });
48
-
49
- And("the pagination total should be {int}", (_, total: number) => {
50
- expect(response.body.pagination.total).toBe(total);
51
- });
52
-
53
- And("the pagination totalPages should be {int}", (_, totalPages: number) => {
54
- expect(response.body.pagination.totalPages).toBe(totalPages);
55
- });
56
- });
57
-
58
- Scenario("Pagination with custom limit", ({ Given, When, Then, And }) => {
59
- Given("I create a mock with 25 items and pagination plugin", () => {
60
- mock = schmock();
61
- mock("GET /items", generateItems(25))
62
- .pipe(queryPlugin({
63
- pagination: { defaultLimit: 10, maxLimit: 100 },
64
- }));
65
- });
66
-
67
- When("I request page 1 with limit 5", async () => {
68
- response = await mock.handle("GET", "/items", {
69
- query: { page: "1", limit: "5" },
70
- });
71
- });
72
-
73
- Then("I should receive {int} items", (_, count: number) => {
74
- expect(response.body.data).toHaveLength(count);
75
- });
76
-
77
- And("the pagination totalPages should be {int}", (_, totalPages: number) => {
78
- expect(response.body.pagination.totalPages).toBe(totalPages);
79
- });
80
- });
81
-
82
- Scenario("Sort items ascending by name", ({ Given, When, Then, And }) => {
83
- Given("I create a mock with named items and sorting plugin", () => {
84
- mock = schmock();
85
- mock("GET /items", namedItems)
86
- .pipe(queryPlugin({
87
- sorting: { allowed: ["name", "id"], default: "id" },
88
- }));
89
- });
90
-
91
- When("I request with sort {string} order {string}", async (_, sort: string, order: string) => {
92
- response = await mock.handle("GET", "/items", {
93
- query: { sort, order },
94
- });
95
- });
96
-
97
- Then("the first item name should be {string}", (_, name: string) => {
98
- expect(response.body[0].name).toBe(name);
99
- });
100
-
101
- And("the last item name should be {string}", (_, name: string) => {
102
- expect(response.body[response.body.length - 1].name).toBe(name);
103
- });
104
- });
105
-
106
- Scenario("Sort items descending by name", ({ Given, When, Then, And }) => {
107
- Given("I create a mock with named items and sorting plugin", () => {
108
- mock = schmock();
109
- mock("GET /items", namedItems)
110
- .pipe(queryPlugin({
111
- sorting: { allowed: ["name", "id"], default: "id" },
112
- }));
113
- });
114
-
115
- When("I request with sort {string} order {string}", async (_, sort: string, order: string) => {
116
- response = await mock.handle("GET", "/items", {
117
- query: { sort, order },
118
- });
119
- });
120
-
121
- Then("the first item name should be {string}", (_, name: string) => {
122
- expect(response.body[0].name).toBe(name);
123
- });
124
-
125
- And("the last item name should be {string}", (_, name: string) => {
126
- expect(response.body[response.body.length - 1].name).toBe(name);
127
- });
128
- });
129
-
130
- Scenario("Filter items by field value", ({ Given, When, Then, And }) => {
131
- Given("I create a mock with categorized items and filtering plugin", () => {
132
- mock = schmock();
133
- mock("GET /items", categorizedItems)
134
- .pipe(queryPlugin({
135
- filtering: { allowed: ["role"] },
136
- }));
137
- });
138
-
139
- When("I request with filter role {string}", async (_, role: string) => {
140
- response = await mock.handle("GET", "/items", {
141
- query: { "filter[role]": role },
142
- });
143
- });
144
-
145
- Then("I should receive {int} filtered items", (_, count: number) => {
146
- expect(response.body).toHaveLength(count);
147
- });
148
-
149
- And("all items should have role {string}", (_, role: string) => {
150
- for (const item of response.body) {
151
- expect(item.role).toBe(role);
152
- }
153
- });
154
- });
155
-
156
- Scenario("Combined pagination, sorting, and filtering", ({ Given, When, Then, And }) => {
157
- const users = [
158
- { id: 1, name: "Dave", role: "admin" },
159
- { id: 2, name: "Alice", role: "user" },
160
- { id: 3, name: "Charlie", role: "admin" },
161
- { id: 4, name: "Bob", role: "admin" },
162
- { id: 5, name: "Eve", role: "user" },
163
- { id: 6, name: "Frank", role: "admin" },
164
- { id: 7, name: "Grace", role: "user" },
165
- { id: 8, name: "Hank", role: "admin" },
166
- { id: 9, name: "Ivy", role: "user" },
167
- { id: 10, name: "Jack", role: "admin" },
168
- { id: 11, name: "Karen", role: "user" },
169
- { id: 12, name: "Leo", role: "admin" },
170
- { id: 13, name: "Mona", role: "user" },
171
- { id: 14, name: "Nate", role: "admin" },
172
- { id: 15, name: "Olivia", role: "user" },
173
- { id: 16, name: "Paul", role: "admin" },
174
- { id: 17, name: "Quinn", role: "user" },
175
- { id: 18, name: "Rose", role: "admin" },
176
- { id: 19, name: "Sam", role: "user" },
177
- { id: 20, name: "Tina", role: "admin" },
178
- ];
179
-
180
- Given("I create a mock with 20 users and full query plugin", () => {
181
- mock = schmock();
182
- mock("GET /users", users)
183
- .pipe(queryPlugin({
184
- pagination: { defaultLimit: 10, maxLimit: 100 },
185
- sorting: { allowed: ["name", "id"] },
186
- filtering: { allowed: ["role"] },
187
- }));
188
- });
189
-
190
- When("I request page 1 with limit 2 filter role {string} and sort {string}", async (_, role: string, sort: string) => {
191
- response = await mock.handle("GET", "/users", {
192
- query: { page: "1", limit: "2", "filter[role]": role, sort },
193
- });
194
- });
195
-
196
- Then("I should receive {int} items", (_, count: number) => {
197
- expect(response.body.data).toHaveLength(count);
198
- });
199
-
200
- And("the items should be sorted by name ascending", () => {
201
- const names = response.body.data.map((item: any) => item.name);
202
- const sorted = [...names].sort();
203
- expect(names).toEqual(sorted);
204
- });
205
-
206
- And("all items should have role {string}", (_, role: string) => {
207
- for (const item of response.body.data) {
208
- expect(item.role).toBe(role);
209
- }
210
- });
211
-
212
- And("the pagination total should reflect filtered count", () => {
213
- // 11 admins in the dataset
214
- expect(response.body.pagination.total).toBe(11);
215
- });
216
- });
217
- });