@nakedev/go-scaffold 0.8.0 → 0.9.0
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/README.md +24 -3
- package/dist/utils/hexagonal-method-patcher.js +7 -1
- package/package.json +1 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +25 -4
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +5 -0
- package/templates/add/rbac/docs/users.yaml.hbs +6 -1
- package/templates/create/base/internal/shared/dbq/dbq.go.hbs +48 -0
- package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +34 -0
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +13 -2
- package/templates/create/features/docs/common/parameters.yaml.hbs +10 -0
- package/templates/create/features/docs/patterns.md.hbs +42 -13
- package/templates/generate/module/docs/collection.yaml.hbs +3 -1
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +7 -1
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +20 -4
- package/templates/generate/module/hexagonal/ports/repository.go.hbs +5 -0
package/README.md
CHANGED
|
@@ -325,6 +325,20 @@ CRUD modules contain the starter list/get/create/update/delete methods. Lean
|
|
|
325
325
|
modules keep the endpoint surface small so it can be extended with
|
|
326
326
|
generate method.
|
|
327
327
|
|
|
328
|
+
Every generated list endpoint reads `?limit=&offset=&q=&sort=&order=`, passes
|
|
329
|
+
one `ports.ListFilter` from handler to application to repository, and answers
|
|
330
|
+
the page beside the total the filter matched. The lists `add auth` and
|
|
331
|
+
`add rbac` own use the same contract. `FindAll` calls `dbq.Search` with no
|
|
332
|
+
columns and carries a `dbq.Sort` with no columns either, so `?q=` and `?sort=`
|
|
333
|
+
are accepted and ignored until you name them in
|
|
334
|
+
`adapters/outbound/postgres/repository.go`; add further filters as fields on
|
|
335
|
+
`ListFilter` rather than as parameters.
|
|
336
|
+
|
|
337
|
+
`dbq.Sort` is why a sort name off the request never reaches the SQL — `ORDER
|
|
338
|
+
BY` takes no bound parameter, so only a key of its `Columns` map is ever
|
|
339
|
+
interpolated — and it adds the tiebreaker and `NULLS LAST` that every stable
|
|
340
|
+
paged list needs.
|
|
341
|
+
|
|
328
342
|
CQRS modules additionally contain:
|
|
329
343
|
|
|
330
344
|
~~~text
|
|
@@ -361,6 +375,13 @@ If the module, method name, or endpoint details are omitted, the wizard asks
|
|
|
361
375
|
for them. The module selector lists modules that exist on disk, so the command
|
|
362
376
|
does not require memorising the normalised Go package name.
|
|
363
377
|
|
|
378
|
+
`generate method` extends the modules `generate module` created, and the one
|
|
379
|
+
`add auth` owns. It refuses `add rbac`'s role module: that module builds its
|
|
380
|
+
HTTP response from a role *together with its permissions* rather than from the
|
|
381
|
+
entity alone, so no generated body fits it. Add an endpoint there by hand — a
|
|
382
|
+
route in `internal/app/role/adapters/inbound/http/handler.go`, a method on the
|
|
383
|
+
application service, and its OpenAPI entry.
|
|
384
|
+
|
|
364
385
|
### Method options
|
|
365
386
|
|
|
366
387
|
| Option | Effect |
|
|
@@ -373,7 +394,7 @@ The generated route and code depend on the method type:
|
|
|
373
394
|
|
|
374
395
|
| Input | Route shape | Result |
|
|
375
396
|
|---|---|---|
|
|
376
|
-
| get --get-mode all | GET /<plural>/<method> |
|
|
397
|
+
| get --get-mode all | GET /<plural>/<method> | Reuses the module's list query, so it inherits ListFilter, ?q= and the total |
|
|
377
398
|
| get --get-mode one --field <field> | GET /<plural>/<field>/:<field> | Adds a FindBy<Field> query and a column/index migration |
|
|
378
399
|
| post | POST /<plural>/<method> | Adds a request body DTO and a TODO service method |
|
|
379
400
|
| put / patch | <VERB> /<plural>/:id/<method> | Loads by ID and leaves the update behavior as a TODO |
|
|
@@ -597,11 +618,11 @@ my-api/
|
|
|
597
618
|
│ │ ├── config/ # environment configuration
|
|
598
619
|
│ │ ├── apperror/ # consistent application errors
|
|
599
620
|
│ │ ├── dberr/ # database error classification
|
|
600
|
-
│ │ ├── dbq/ # escaped contains-search
|
|
621
|
+
│ │ ├── dbq/ # escaped contains-search and whitelisted sort
|
|
601
622
|
│ │ ├── httpx/ # HTTP parsing and binding helpers
|
|
602
623
|
│ │ ├── id/ # UUID generation
|
|
603
624
|
│ │ ├── middleware/ # request ID, logging, errors, CORS
|
|
604
|
-
│ │ ├── pagination/ #
|
|
625
|
+
│ │ ├── pagination/ # ?limit/offset/q/sort/order parsing, responses
|
|
605
626
|
│ │ └── tx/ # transaction context helpers
|
|
606
627
|
│ └── app/ # empty until generate module is used
|
|
607
628
|
├── migrations/ # embedded, versioned SQL migrations
|
|
@@ -140,7 +140,13 @@ function handlerMethod(naming, method, opts, cqrs, routeReceiver, errorMapper, a
|
|
|
140
140
|
body: [
|
|
141
141
|
`func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
|
|
142
142
|
`\tp := pagination.Parse(c)`,
|
|
143
|
-
`\tfilter := ports.ListFilter{
|
|
143
|
+
`\tfilter := ports.ListFilter{`,
|
|
144
|
+
`\t\tSearch: p.Search,`,
|
|
145
|
+
`\t\tSort: p.Sort,`,
|
|
146
|
+
`\t\tDesc: p.Desc,`,
|
|
147
|
+
`\t\tLimit: p.Limit,`,
|
|
148
|
+
`\t\tOffset: p.Offset,`,
|
|
149
|
+
`\t}`,
|
|
144
150
|
`\titems, total, err := ${receiver}.${method.pascalName}(c.Request.Context(), filter)`,
|
|
145
151
|
`\tif err != nil {`,
|
|
146
152
|
`\t\tc.Error(${errorMapper}(err))`,
|
package/package.json
CHANGED
|
@@ -157,7 +157,13 @@ func (h *Handler) adminListUsers(c *gin.Context) {
|
|
|
157
157
|
p := pagination.Parse(c)
|
|
158
158
|
// One struct all the way down, so a filter added later is a field on
|
|
159
159
|
// ports.ListFilter and not a new argument on the three signatures below.
|
|
160
|
-
filter := ports.ListFilter{
|
|
160
|
+
filter := ports.ListFilter{
|
|
161
|
+
Search: p.Search,
|
|
162
|
+
Sort: p.Sort,
|
|
163
|
+
Desc: p.Desc,
|
|
164
|
+
Limit: p.Limit,
|
|
165
|
+
Offset: p.Offset,
|
|
166
|
+
}
|
|
161
167
|
items, total, err := h.svc.List(c.Request.Context(), filter)
|
|
162
168
|
if err != nil {
|
|
163
169
|
c.Error(toHTTPError(err))
|
|
@@ -77,6 +77,25 @@ func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
|
|
|
77
77
|
}))
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// sortable is the whole of what ?sort= may say on the admin user list: dbq.Sort
|
|
81
|
+
// resolves the requested name against Columns, so nothing off the request is
|
|
82
|
+
// ever interpolated into the ORDER BY.
|
|
83
|
+
var sortable = dbq.Sort{
|
|
84
|
+
Columns: map[string]string{
|
|
85
|
+
"name": "name",
|
|
86
|
+
"role": "role",
|
|
87
|
+
"created_at": "created_at",
|
|
88
|
+
// The address lives one table over, and only one row per user is
|
|
89
|
+
// primary (idx_user_emails_primary), so the subquery answers exactly
|
|
90
|
+
// one value. Correlated, so it runs per matching row — fine for a
|
|
91
|
+
// staff table; make it a LEFT JOIN on that index if this ever pages
|
|
92
|
+
// thousands, and give the query its own SELECT list when you do.
|
|
93
|
+
"email": "(SELECT e.email FROM user_svc.user_emails e WHERE e.user_id = user_svc.users.id AND e.is_primary)",
|
|
94
|
+
},
|
|
95
|
+
Default: "created_at desc",
|
|
96
|
+
Tiebreak: "id",
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
// FindAll answers one page of the filter and how many accounts it matched
|
|
81
100
|
// altogether. Two queries for that, because a count over a LIMITed query would
|
|
82
101
|
// only ever count the page.
|
|
@@ -104,10 +123,12 @@ func (r *Repository) FindAll(ctx context.Context, filter ports.ListFilter) ([]do
|
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
var rows []User
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
126
|
+
// Newest account first until a client asks for something else, and always
|
|
127
|
+
// with the tiebreaker sortable carries: without it two accounts created in
|
|
128
|
+
// the same instant can swap between page 1 and page 2, showing one twice
|
|
129
|
+
// and hiding the other.
|
|
130
|
+
order := sortable.OrderBy(filter.Sort, filter.Desc)
|
|
131
|
+
err := matching().Order(order).Limit(filter.Limit).Offset(filter.Offset).Find(&rows).Error
|
|
111
132
|
if err != nil {
|
|
112
133
|
return nil, 0, persistenceError(err)
|
|
113
134
|
}
|
|
@@ -21,6 +21,11 @@ type ListFilter struct {
|
|
|
21
21
|
// Search is ?q= as shared/pagination parsed it, matched against the name
|
|
22
22
|
// and the primary email address.
|
|
23
23
|
Search string
|
|
24
|
+
// Sort is ?sort= and Desc is ?order=desc, both as shared/pagination parsed
|
|
25
|
+
// them. Which column names are accepted is the repository's to say — see
|
|
26
|
+
// the dbq.Sort beside its FindAll.
|
|
27
|
+
Sort string
|
|
28
|
+
Desc bool
|
|
24
29
|
Limit int
|
|
25
30
|
Offset int
|
|
26
31
|
}
|
|
@@ -7,9 +7,14 @@ get:
|
|
|
7
7
|
- $ref: '../common/parameters.yaml#/Limit'
|
|
8
8
|
- $ref: '../common/parameters.yaml#/Offset'
|
|
9
9
|
- $ref: '../common/parameters.yaml#/Search'
|
|
10
|
+
- $ref: '../common/parameters.yaml#/Sort'
|
|
11
|
+
- $ref: '../common/parameters.yaml#/Order'
|
|
10
12
|
responses:
|
|
11
13
|
"200":
|
|
12
|
-
description:
|
|
14
|
+
description: >-
|
|
15
|
+
paginated list; `q` matches name and primary email, and `sort` takes
|
|
16
|
+
name, email, role or created_at — anything else is the default order,
|
|
17
|
+
newest account first
|
|
13
18
|
content:
|
|
14
19
|
application/json:
|
|
15
20
|
schema:
|
|
@@ -45,3 +45,51 @@ func Search(db *gorm.DB, term string, cols ...string) *gorm.DB {
|
|
|
45
45
|
// narrowing it.
|
|
46
46
|
return db.Where("("+strings.Join(conds, " OR ")+")", args...)
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
// Sort is a list endpoint's ordering contract: the columns it accepts, the
|
|
50
|
+
// order it has before anyone asks for one, and the column that breaks ties.
|
|
51
|
+
// Declare it once per repository, beside the FindAll that uses it.
|
|
52
|
+
type Sort struct {
|
|
53
|
+
// Columns maps the names the API accepts to the SQL each one means, and is
|
|
54
|
+
// the whole of what can reach the clause: ORDER BY takes no bound
|
|
55
|
+
// parameter, so a ?sort= that is not a key here is never interpolated. A
|
|
56
|
+
// value the table does not hold — an address one table over — is a
|
|
57
|
+
// subquery rather than a join, which would need its own SELECT list to
|
|
58
|
+
// keep Find scanning the right row.
|
|
59
|
+
Columns map[string]string
|
|
60
|
+
// Default is the order for a request that asked for nothing Columns knows,
|
|
61
|
+
// written without the tiebreaker: OrderBy adds it.
|
|
62
|
+
Default string
|
|
63
|
+
// Tiebreak is a column unique enough to page by, usually the primary key.
|
|
64
|
+
// Without it two rows equal on the sorted column can swap places between
|
|
65
|
+
// page 1 and page 2, showing one twice and hiding the other. Leave it empty
|
|
66
|
+
// only when Columns and Default are unique on their own.
|
|
67
|
+
Tiebreak string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// OrderBy resolves what the request asked for into an ORDER BY clause:
|
|
71
|
+
//
|
|
72
|
+
// q = q.Order(thingSort.OrderBy(filter.Sort, filter.Desc))
|
|
73
|
+
//
|
|
74
|
+
// Sorted columns get NULLS LAST in both directions, because a row with no
|
|
75
|
+
// value is missing an answer rather than holding the largest one — the account
|
|
76
|
+
// that has never signed in belongs at the bottom whichever way the column is
|
|
77
|
+
// pointed. It is a no-op on a column that cannot be null.
|
|
78
|
+
func (s Sort) OrderBy(sort string, desc bool) string {
|
|
79
|
+
clause := s.Default
|
|
80
|
+
if col, ok := s.Columns[sort]; ok {
|
|
81
|
+
dir := "asc"
|
|
82
|
+
if desc {
|
|
83
|
+
dir = "desc"
|
|
84
|
+
}
|
|
85
|
+
clause = col + " " + dir + " NULLS LAST"
|
|
86
|
+
}
|
|
87
|
+
switch {
|
|
88
|
+
case s.Tiebreak == "":
|
|
89
|
+
return clause
|
|
90
|
+
case clause == "":
|
|
91
|
+
return s.Tiebreak
|
|
92
|
+
default:
|
|
93
|
+
return clause + ", " + s.Tiebreak
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -64,3 +64,37 @@ func TestSearchWithoutATermIsANoOp(t *testing.T) {
|
|
|
64
64
|
t.Errorf("empty term still filtered: %s", stmt.SQL.String())
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
// The whitelist is the security boundary: ?sort= comes off the request, and
|
|
69
|
+
// ORDER BY takes no bound parameter. The rest is the two rules every list gets
|
|
70
|
+
// for free — a tiebreaker, and empty values last.
|
|
71
|
+
func TestSortOrderBy(t *testing.T) {
|
|
72
|
+
sorter := Sort{
|
|
73
|
+
Columns: map[string]string{"name": "name", "seen_at": "seen_at"},
|
|
74
|
+
Default: "created_at desc",
|
|
75
|
+
Tiebreak: "id",
|
|
76
|
+
}
|
|
77
|
+
for _, tc := range []struct {
|
|
78
|
+
sort string
|
|
79
|
+
desc bool
|
|
80
|
+
want string
|
|
81
|
+
}{
|
|
82
|
+
{"name", false, "name asc NULLS LAST, id"},
|
|
83
|
+
{"seen_at", true, "seen_at desc NULLS LAST, id"},
|
|
84
|
+
// Nobody asked for an order.
|
|
85
|
+
{"", false, "created_at desc, id"},
|
|
86
|
+
// A name that is not on the list is the default order, not SQL.
|
|
87
|
+
{"name; DROP TABLE things --", true, "created_at desc, id"},
|
|
88
|
+
} {
|
|
89
|
+
if got := sorter.OrderBy(tc.sort, tc.desc); got != tc.want {
|
|
90
|
+
t.Errorf("OrderBy(%q, %v) = %q, want %q", tc.sort, tc.desc, got, tc.want)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A table keyed by something unique on its own needs no tiebreaker, and
|
|
95
|
+
// must not be given one it has no column for.
|
|
96
|
+
byCode := Sort{Columns: map[string]string{"name": "name"}, Tiebreak: "code"}
|
|
97
|
+
if got := byCode.OrderBy("", false); got != "code" {
|
|
98
|
+
t.Errorf("no default and no sort should be the tiebreaker alone, got %q", got)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -24,10 +24,17 @@ type Params struct {
|
|
|
24
24
|
// Search is ?q= trimmed, empty when the caller sent none — so a repository
|
|
25
25
|
// can pass it straight to dbq.Search, which no-ops on an empty term.
|
|
26
26
|
Search string
|
|
27
|
+
// Sort is ?sort=, the column the client asked to be ordered by, and Desc
|
|
28
|
+
// is ?order=desc. Nothing is validated here on purpose: which names an
|
|
29
|
+
// endpoint accepts is its repository's to say, and dbq.Sort is where that
|
|
30
|
+
// whitelist is applied — a name it does not know falls back to the list's
|
|
31
|
+
// own order rather than failing the request.
|
|
32
|
+
Sort string
|
|
33
|
+
Desc bool
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
// Parse reads ?limit=&offset=&q= off the request so every feature
|
|
30
|
-
// same way.
|
|
36
|
+
// Parse reads ?limit=&offset=&q=&sort=&order= off the request so every feature
|
|
37
|
+
// parses the same way.
|
|
31
38
|
func Parse(c *gin.Context) Params {
|
|
32
39
|
p := Params{Limit: defaultLimit}
|
|
33
40
|
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
|
|
@@ -47,6 +54,10 @@ func Parse(c *gin.Context) Params {
|
|
|
47
54
|
}
|
|
48
55
|
p.Search = q
|
|
49
56
|
}
|
|
57
|
+
p.Sort = strings.TrimSpace(c.Query("sort"))
|
|
58
|
+
// Ascending unless asked otherwise: one spelling of "the other way", and
|
|
59
|
+
// anything else read as the default rather than refused.
|
|
60
|
+
p.Desc = c.Query("order") == "desc"
|
|
50
61
|
return p
|
|
51
62
|
}
|
|
52
63
|
|
|
@@ -16,3 +16,13 @@ Search:
|
|
|
16
16
|
in: query
|
|
17
17
|
description: "free-text search; which columns it matches is documented per endpoint"
|
|
18
18
|
schema: { type: string, maxLength: 100 }
|
|
19
|
+
Sort:
|
|
20
|
+
name: sort
|
|
21
|
+
in: query
|
|
22
|
+
description: "column to order by; which columns are accepted is documented per endpoint, and anything else is read as that list's own default order"
|
|
23
|
+
schema: { type: string }
|
|
24
|
+
Order:
|
|
25
|
+
name: order
|
|
26
|
+
in: query
|
|
27
|
+
description: "direction for `sort`; ascending unless this is `desc`. Rows with no value sort last either way"
|
|
28
|
+
schema: { type: string, enum: [asc, desc], default: asc }
|
|
@@ -255,9 +255,10 @@ business logic — same spirit as `generate module`'s placeholder fields.
|
|
|
255
255
|
A `crud`-surface module is generated with this already wired; a module that
|
|
256
256
|
grows a list later copies the same four steps. Nothing about it is magic.
|
|
257
257
|
|
|
258
|
-
**1. Parse at the boundary.** `pagination.Parse(c)` returns `Limit`, `Offset
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
**1. Parse at the boundary.** `pagination.Parse(c)` returns `Limit`, `Offset`,
|
|
259
|
+
`Search` (`?q=`, trimmed and capped at 100 runes) and the requested order —
|
|
260
|
+
`Sort` (`?sort=`) and `Desc` (`?order=desc`). Read the filters this endpoint
|
|
261
|
+
owns off the query string beside them. An unreadable filter value means
|
|
261
262
|
*no* filter, not `400` — a list narrows on a best effort.
|
|
262
263
|
|
|
263
264
|
**2. One filter struct, in `ports/`.** `ports.ListFilter` travels whole through
|
|
@@ -270,6 +271,8 @@ everything".
|
|
|
270
271
|
```go
|
|
271
272
|
type ListFilter struct {
|
|
272
273
|
Search string
|
|
274
|
+
Sort string
|
|
275
|
+
Desc bool
|
|
273
276
|
Limit int
|
|
274
277
|
Offset int
|
|
275
278
|
// Status, OwnerID, DateFrom … whatever this module actually filters by.
|
|
@@ -288,21 +291,47 @@ accumulate on a `*gorm.DB` value, so a count reusing the page's query object
|
|
|
288
291
|
silently inherits its `LIMIT`. Use `dbq.Search(q, term, cols...)` for a
|
|
289
292
|
contains-search over columns on the same table, and `dbq.LikePattern(term)`
|
|
290
293
|
when the search spans a subquery or a join. Both escape `%` and `_`, so
|
|
291
|
-
someone typing `50%` searches for `50%` instead of matching every row.
|
|
292
|
-
keep a tiebreaker in the sort (`ORDER BY created_at DESC, id`) or two rows
|
|
293
|
-
written in the same instant can swap between page 1 and page 2 — showing one
|
|
294
|
-
twice and hiding the other.
|
|
294
|
+
someone typing `50%` searches for `50%` instead of matching every row.
|
|
295
295
|
|
|
296
|
-
The
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
296
|
+
The order is a `dbq.Sort` declared beside `FindAll`, not an `Order` string
|
|
297
|
+
written per query:
|
|
298
|
+
|
|
299
|
+
```go
|
|
300
|
+
var sortable = dbq.Sort{
|
|
301
|
+
Columns: map[string]string{"name": "name", "created_at": "created_at"},
|
|
302
|
+
Default: "created_at desc",
|
|
303
|
+
Tiebreak: "id",
|
|
304
|
+
}
|
|
305
|
+
...
|
|
306
|
+
q = q.Order(sortable.OrderBy(filter.Sort, filter.Desc))
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`Columns` maps the names the API accepts to the SQL each one means and is the
|
|
310
|
+
whole of what can reach the clause — `ORDER BY` takes no bound parameter, so a
|
|
311
|
+
`?sort=` that is not one of its keys falls back to `Default` rather than being
|
|
312
|
+
interpolated. `OrderBy` adds what every list needs and nobody should have to
|
|
313
|
+
remember: `Tiebreak` on the end (without it two rows equal on the sorted column
|
|
314
|
+
swap between page 1 and page 2, showing one twice and hiding the other) and
|
|
315
|
+
`NULLS LAST` in both directions (a row with no value is missing an answer, not
|
|
316
|
+
holding the largest one). A table already unique on its order — roles, keyed by
|
|
317
|
+
`code` — sets `Tiebreak` to that column and leaves `Default` empty.
|
|
318
|
+
|
|
319
|
+
A column the client sees but this table does not hold, such as a primary email
|
|
320
|
+
one table over, belongs in `Columns` as a subquery rather than a join: a join
|
|
321
|
+
would need its own `SELECT` list to keep `Find` scanning the right row.
|
|
322
|
+
|
|
323
|
+
The generated `FindAll` calls `dbq.Search` with no columns and carries an empty
|
|
324
|
+
`Columns` map, both no-ops: `?q=` and `?sort=` are accepted and ignored until
|
|
325
|
+
you name the columns this list is searched and ordered by. Those two TODOs are
|
|
326
|
+
the lines standing between the scaffold and a working list.
|
|
300
327
|
|
|
301
328
|
**4. Respond with the shared envelope.** `p.ResponseWithTotal(out, total)` —
|
|
302
329
|
`{ data, limit, offset, total }`, the same shape for every resource. Anything
|
|
303
330
|
extra is a named key added to that map and documented in the endpoint's
|
|
304
|
-
OpenAPI file. Reuse `common/parameters.yaml#/Search`, `#/Limit
|
|
305
|
-
for the query parameters.
|
|
331
|
+
OpenAPI file. Reuse `common/parameters.yaml#/Search`, `#/Limit`, `#/Offset`,
|
|
332
|
+
`#/Sort` and `#/Order` for the query parameters. `#/Sort` carries no enum —
|
|
333
|
+
which columns an endpoint accepts is a sentence in its own response
|
|
334
|
+
description, the same way `q` says what it matches.
|
|
306
335
|
|
|
307
336
|
Counts that answer a *different* question from the page — "how many are open
|
|
308
337
|
and how many are closed, whichever tab is showing" — belong beside the
|
|
@@ -22,9 +22,11 @@ get:
|
|
|
22
22
|
- $ref: '../common/parameters.yaml#/Limit'
|
|
23
23
|
- $ref: '../common/parameters.yaml#/Offset'
|
|
24
24
|
- $ref: '../common/parameters.yaml#/Search'
|
|
25
|
+
- $ref: '../common/parameters.yaml#/Sort'
|
|
26
|
+
- $ref: '../common/parameters.yaml#/Order'
|
|
25
27
|
responses:
|
|
26
28
|
"200":
|
|
27
|
-
description: "paginated list; narrow it with ?q= and whatever filters this module adds"
|
|
29
|
+
description: "paginated list; narrow it with ?q= and whatever filters this module adds, and order it with ?sort= once the repository's dbq.Sort names a column"
|
|
28
30
|
content:
|
|
29
31
|
application/json:
|
|
30
32
|
schema:
|
|
@@ -111,7 +111,13 @@ func (h *Handler) list(c *gin.Context) {
|
|
|
111
111
|
// One struct all the way down, so this module's own filters are read off
|
|
112
112
|
// the query string here and added as fields on ports.ListFilter — never as
|
|
113
113
|
// extra arguments on the three signatures below.
|
|
114
|
-
filter := ports.ListFilter{
|
|
114
|
+
filter := ports.ListFilter{
|
|
115
|
+
Search: p.Search,
|
|
116
|
+
Sort: p.Sort,
|
|
117
|
+
Desc: p.Desc,
|
|
118
|
+
Limit: p.Limit,
|
|
119
|
+
Offset: p.Offset,
|
|
120
|
+
}
|
|
115
121
|
{{#if cqrs}}
|
|
116
122
|
items, total, err := h.queries.List(c.Request.Context(), filter)
|
|
117
123
|
{{else}}
|
|
@@ -31,6 +31,20 @@ func (r *Repository) Create(ctx context.Context, m *domain.{{pascalName}}) error
|
|
|
31
31
|
return nil
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// sortable is the whole of what ?sort= may say for this list: dbq.Sort
|
|
35
|
+
// resolves the requested name against Columns, so nothing off the request is
|
|
36
|
+
// ever interpolated into the ORDER BY.
|
|
37
|
+
//
|
|
38
|
+
// TODO: name the columns this list may be ordered by, mapping the name the API
|
|
39
|
+
// accepts to the SQL it means — {"name": "name", "created_at": "created_at"}.
|
|
40
|
+
// Until then ?sort= is accepted and ignored, the same way dbq.Search is until
|
|
41
|
+
// it has columns.
|
|
42
|
+
var sortable = dbq.Sort{
|
|
43
|
+
Columns: map[string]string{},
|
|
44
|
+
Default: "created_at desc",
|
|
45
|
+
Tiebreak: "id",
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
// FindAll answers one page of the filter and how many rows it matched
|
|
35
49
|
// altogether. Two queries, because a count over a LIMITed query would only
|
|
36
50
|
// ever count the page.
|
|
@@ -52,10 +66,12 @@ func (r *Repository) FindAll(ctx context.Context, filter ports.ListFilter) ([]do
|
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
var rows []{{pascalName}}Model
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// showing one twice and hiding
|
|
58
|
-
|
|
69
|
+
// Newest first until a client asks for something else, and always with the
|
|
70
|
+
// tiebreaker sortable carries: without it two rows written in the same
|
|
71
|
+
// instant can swap between page 1 and page 2, showing one twice and hiding
|
|
72
|
+
// the other.
|
|
73
|
+
order := sortable.OrderBy(filter.Sort, filter.Desc)
|
|
74
|
+
if err := matching().Order(order).Limit(filter.Limit).Offset(filter.Offset).Find(&rows).Error; err != nil {
|
|
59
75
|
return nil, 0, mapDatabaseError(err)
|
|
60
76
|
}
|
|
61
77
|
items := make([]domain.{{pascalName}}, len(rows))
|
|
@@ -21,6 +21,11 @@ type ListFilter struct {
|
|
|
21
21
|
// Search is ?q= as shared/pagination parsed it, empty when the caller
|
|
22
22
|
// sent none.
|
|
23
23
|
Search string
|
|
24
|
+
// Sort is ?sort= and Desc is ?order=desc, both as shared/pagination parsed
|
|
25
|
+
// them. Which column names are accepted is the repository's to say — see
|
|
26
|
+
// the dbq.Sort beside its FindAll.
|
|
27
|
+
Sort string
|
|
28
|
+
Desc bool
|
|
24
29
|
Limit int
|
|
25
30
|
Offset int
|
|
26
31
|
}
|