Skip to content

Commit ada86ac

Browse files
typicodeCopilot
andauthored
feat: add _where filtering, use new op separator, drop _start, _end, _limit (#1696)
* feat: add _where filtering and new op separator * Restore original README content above Query capabilities overview (#1697) * test: refactor service tests to lightweight table cases * chore: document underscore where operator compatibility * refactor: clarify blank-string handling in where coercion --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent 221f2b8 commit ada86ac

File tree

13 files changed

+674
-565
lines changed

13 files changed

+674
-565
lines changed

README.md

Lines changed: 51 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -110,107 +110,108 @@ Run `json-server --help` for a list of options
110110
>
111111
> For more information, FAQs, and the rationale behind this, visit [https://fair.io/](https://fair.io/).
112112
113+
## Query capabilities overview
114+
115+
```http
116+
GET /posts?views:gt=100
117+
GET /posts?_sort=-views
118+
GET /posts?_page=1&_per_page=10
119+
GET /posts?_embed=comments
120+
GET /posts?_where={"or":[{"views":{"gt":100}},{"title":{"eq":"Hello"}}]}
121+
```
122+
113123
## Routes
114124

115-
Based on the example `db.json`, you'll get the following routes:
125+
For array resources (`posts`, `comments`):
116126

117-
```
127+
```text
118128
GET /posts
119129
GET /posts/:id
120130
POST /posts
121131
PUT /posts/:id
122132
PATCH /posts/:id
123133
DELETE /posts/:id
124-
125-
# Same for comments
126134
```
127135

128-
```
136+
For object resources (`profile`):
137+
138+
```text
129139
GET /profile
130140
PUT /profile
131141
PATCH /profile
132142
```
133143

134-
## Params
144+
## Query params
135145

136146
### Conditions
137147

138-
- ` ``==`
139-
- `lt``<`
140-
- `lte``<=`
141-
- `gt``>`
142-
- `gte``>=`
143-
- `ne``!=`
148+
Use `field:operator=value`.
144149

145-
```
146-
GET /posts?views_gt=9000
147-
```
150+
Operators:
148151

149-
### Range
152+
- no operator -> `eq` (equal)
153+
- `lt` less than, `lte` less than or equal
154+
- `gt` greater than, `gte` greater than or equal
155+
- `eq` equal, `ne` not equal
150156

151-
- `start`
152-
- `end`
153-
- `limit`
157+
Examples:
154158

155-
```
156-
GET /posts?_start=10&_end=20
157-
GET /posts?_start=10&_limit=10
158-
```
159-
160-
### Paginate
161-
162-
- `page`
163-
- `per_page` (default = 10)
164-
165-
```
166-
GET /posts?_page=1&_per_page=25
159+
```http
160+
GET /posts?views:gt=100
161+
GET /posts?title:eq=Hello
162+
GET /posts?author.name:eq=typicode
167163
```
168164

169165
### Sort
170166

171-
- `_sort=f1,f2`
172-
167+
```http
168+
GET /posts?_sort=title
169+
GET /posts?_sort=-views
170+
GET /posts?_sort=author.name,-views
173171
```
174-
GET /posts?_sort=id,-views
175-
```
176-
177-
### Nested and array fields
178172

179-
- `x.y.z...`
180-
- `x.y.z[i]...`
173+
### Pagination
181174

175+
```http
176+
GET /posts?_page=1&_per_page=25
182177
```
183-
GET /foo?a.b=bar
184-
GET /foo?x.y_lt=100
185-
GET /foo?arr[0]=bar
186-
```
178+
179+
- `_per_page` default is `10`
180+
- invalid page/per_page values are normalized
187181

188182
### Embed
189183

190-
```
184+
```http
191185
GET /posts?_embed=comments
192186
GET /comments?_embed=post
193187
```
194188

195-
## Delete
189+
### Complex filter with `_where`
196190

191+
`_where` accepts a JSON object and overrides normal query params when valid.
192+
193+
```http
194+
GET /posts?_where={"or":[{"views":{"gt":100}},{"author":{"name":{"lt":"m"}}}]}
197195
```
198-
DELETE /posts/1
196+
197+
## Delete dependents
198+
199+
```http
199200
DELETE /posts/1?_dependent=comments
200201
```
201202

202-
## Serving static files
203+
## Static files
203204

204-
If you create a `./public` directory, JSON Server will serve its content in addition to the REST API.
205+
JSON Server serves `./public` automatically.
205206

206-
You can also add custom directories using `-s/--static` option.
207+
Add more static dirs:
207208

208209
```sh
209210
json-server -s ./static
210211
json-server -s ./static -s ./node_modules
211212
```
212213

213-
## Notable differences with v0.17
214+
## Behavior notes
214215

215216
- `id` is always a string and will be generated for you if missing
216217
- use `_per_page` with `_page` instead of `_limit`for pagination

src/app.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,28 @@ await test('createApp', async (t) => {
118118
)
119119
})
120120
}
121+
122+
await t.test('GET /posts?_where=... uses JSON query', async () => {
123+
// Reset data since previous tests may have modified it
124+
db.data = {
125+
posts: [{ id: '1', title: 'foo' }],
126+
comments: [{ id: '1', postId: '1' }],
127+
object: { f1: 'foo' },
128+
}
129+
const where = encodeURIComponent(JSON.stringify({ title: { eq: 'foo' } }))
130+
const response = await fetch(`http://localhost:${port}/posts?_where=${where}`)
131+
assert.equal(response.status, 200)
132+
const data = await response.json()
133+
assert.deepEqual(data, [{ id: '1', title: 'foo' }])
134+
})
135+
136+
await t.test('GET /posts?_where=... overrides query params', async () => {
137+
const where = encodeURIComponent(JSON.stringify({ title: { eq: 'foo' } }))
138+
const response = await fetch(
139+
`http://localhost:${port}/posts?title:eq=bar&_where=${where}`,
140+
)
141+
assert.equal(response.status, 200)
142+
const data = await response.json()
143+
assert.deepEqual(data, [{ id: '1', title: 'foo' }])
144+
})
121145
})

src/app.ts

Lines changed: 77 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
import { dirname, isAbsolute, join } from 'node:path'
22
import { fileURLToPath } from 'node:url'
33

4-
import { App, type Request } from '@tinyhttp/app'
4+
import { App } from '@tinyhttp/app'
55
import { cors } from '@tinyhttp/cors'
66
import { Eta } from 'eta'
77
import { Low } from 'lowdb'
88
import { json } from 'milliparsec'
99
import sirv from 'sirv'
1010

11+
import { parseWhere } from './parse-where.ts'
1112
import type { Data } from './service.ts'
1213
import { isItem, Service } from './service.ts'
1314

1415
const __dirname = dirname(fileURLToPath(import.meta.url))
1516
const isProduction = process.env['NODE_ENV'] === 'production'
1617

17-
type QueryValue = Request['query'][string] | number
18-
type Query = Record<string, QueryValue>
19-
2018
export type AppOptions = {
2119
logger?: boolean
2220
static?: string[]
@@ -27,6 +25,68 @@ const eta = new Eta({
2725
cache: isProduction,
2826
})
2927

28+
const RESERVED_QUERY_KEYS = new Set(['_sort', '_page', '_per_page', '_embed', '_where'])
29+
30+
function parseListParams(req: any) {
31+
const queryString = req.url.split('?')[1] ?? ''
32+
const params = new URLSearchParams(queryString)
33+
34+
const filterParams = new URLSearchParams()
35+
for (const [key, value] of params.entries()) {
36+
if (!RESERVED_QUERY_KEYS.has(key)) {
37+
filterParams.append(key, value)
38+
}
39+
}
40+
41+
let where = parseWhere(filterParams.toString())
42+
const rawWhere = params.get('_where')
43+
if (typeof rawWhere === 'string') {
44+
try {
45+
const parsed = JSON.parse(rawWhere)
46+
if (typeof parsed === 'object' && parsed !== null) {
47+
where = parsed
48+
}
49+
} catch {
50+
// Ignore invalid JSON and fallback to parsed query params
51+
}
52+
}
53+
54+
const pageRaw = params.get('_page')
55+
const perPageRaw = params.get('_per_page')
56+
const page = pageRaw === null ? undefined : Number.parseInt(pageRaw, 10)
57+
const perPage = perPageRaw === null ? undefined : Number.parseInt(perPageRaw, 10)
58+
59+
return {
60+
where,
61+
sort: params.get('_sort') ?? undefined,
62+
page: Number.isNaN(page) ? undefined : page,
63+
perPage: Number.isNaN(perPage) ? undefined : perPage,
64+
embed: req.query['_embed'],
65+
}
66+
}
67+
68+
function withBody(action: (name: string, body: Record<string, unknown>) => Promise<unknown>) {
69+
return async (req: any, res: any, next: any) => {
70+
const { name = '' } = req.params
71+
if (isItem(req.body)) {
72+
res.locals['data'] = await action(name, req.body)
73+
}
74+
next?.()
75+
}
76+
}
77+
78+
function withIdAndBody(
79+
action: (name: string, id: string, body: Record<string, unknown>) => Promise<unknown>,
80+
) {
81+
return async (req: any, res: any, next: any) => {
82+
const { name = '', id = '' } = req.params
83+
if (isItem(req.body)) {
84+
res.locals['data'] = await action(name, id, req.body)
85+
}
86+
next?.()
87+
}
88+
}
89+
3090
export function createApp(db: Low<Data>, options: AppOptions = {}) {
3191
// Create service
3292
const service = new Service(db)
@@ -58,23 +118,15 @@ export function createApp(db: Low<Data>, options: AppOptions = {}) {
58118

59119
app.get('/:name', (req, res, next) => {
60120
const { name = '' } = req.params
61-
const query: Query = {}
62-
63-
Object.keys(req.query).forEach((key) => {
64-
let value: QueryValue = req.query[key]
65-
66-
if (
67-
['_start', '_end', '_limit', '_page', '_per_page'].includes(key) &&
68-
typeof value === 'string'
69-
) {
70-
value = parseInt(value)
71-
}
72-
73-
if (!Number.isNaN(value)) {
74-
query[key] = value
75-
}
121+
const { where, sort, page, perPage, embed } = parseListParams(req)
122+
123+
res.locals['data'] = service.find(name, {
124+
where,
125+
sort,
126+
page,
127+
perPage,
128+
embed,
76129
})
77-
res.locals['data'] = service.find(name, query)
78130
next?.()
79131
})
80132

@@ -84,45 +136,15 @@ export function createApp(db: Low<Data>, options: AppOptions = {}) {
84136
next?.()
85137
})
86138

87-
app.post('/:name', async (req, res, next) => {
88-
const { name = '' } = req.params
89-
if (isItem(req.body)) {
90-
res.locals['data'] = await service.create(name, req.body)
91-
}
92-
next?.()
93-
})
139+
app.post('/:name', withBody(service.create.bind(service)))
94140

95-
app.put('/:name', async (req, res, next) => {
96-
const { name = '' } = req.params
97-
if (isItem(req.body)) {
98-
res.locals['data'] = await service.update(name, req.body)
99-
}
100-
next?.()
101-
})
141+
app.put('/:name', withBody(service.update.bind(service)))
102142

103-
app.put('/:name/:id', async (req, res, next) => {
104-
const { name = '', id = '' } = req.params
105-
if (isItem(req.body)) {
106-
res.locals['data'] = await service.updateById(name, id, req.body)
107-
}
108-
next?.()
109-
})
143+
app.put('/:name/:id', withIdAndBody(service.updateById.bind(service)))
110144

111-
app.patch('/:name', async (req, res, next) => {
112-
const { name = '' } = req.params
113-
if (isItem(req.body)) {
114-
res.locals['data'] = await service.patch(name, req.body)
115-
}
116-
next?.()
117-
})
145+
app.patch('/:name', withBody(service.patch.bind(service)))
118146

119-
app.patch('/:name/:id', async (req, res, next) => {
120-
const { name = '', id = '' } = req.params
121-
if (isItem(req.body)) {
122-
res.locals['data'] = await service.patchById(name, id, req.body)
123-
}
124-
next?.()
125-
})
147+
app.patch('/:name/:id', withIdAndBody(service.patchById.bind(service)))
126148

127149
app.delete('/:name/:id', async (req, res, next) => {
128150
const { name = '', id = '' } = req.params

0 commit comments

Comments
 (0)