@uniweb/api 0.3.7 → 0.4.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/README.md +99 -23
- package/bin/uniweb-api-mock.js +20 -2
- package/package.json +1 -1
- package/src/client.js +239 -84
- package/src/errors.js +39 -12
- package/src/hooks/useEntity.js +9 -4
- package/src/hooks/useEntityWriter.js +42 -26
- package/src/hooks/useRecords.js +19 -6
- package/src/http.js +11 -6
- package/src/ledger.js +87 -55
- package/src/mock/index.js +333 -116
- package/src/mock/node.js +6 -2
- package/src/mock/schema-shape.js +8 -0
- package/src/mock/seed.js +39 -22
- package/src/mock/store.js +604 -295
- package/src/wire.js +327 -226
package/README.md
CHANGED
|
@@ -43,22 +43,38 @@ function Account() {
|
|
|
43
43
|
</SignedIn>
|
|
44
44
|
<SignedOut>
|
|
45
45
|
<button onClick={() => signIn({ username, password })}>Sign in</button>
|
|
46
|
-
{error && <span>
|
|
46
|
+
{error?.kind === 'unverified' && <span>Check your email to confirm your address.</span>}
|
|
47
|
+
{error?.kind === 'auth' && <span>Wrong username or password.</span>}
|
|
47
48
|
</SignedOut>
|
|
48
49
|
</>
|
|
49
50
|
)
|
|
50
51
|
}
|
|
51
52
|
```
|
|
52
53
|
|
|
53
|
-
`viewer` is flat — `viewer.
|
|
54
|
-
|
|
54
|
+
`viewer` is flat — `viewer.uuid`, `viewer.username`, `viewer.handle`, and `viewer.roles`,
|
|
55
|
+
a list of `{ role, scope_unit_id }`. A member holds no roles (`[]`); the site's operator
|
|
56
|
+
holds `system_admin`. ⚠️ `viewer.actingUnitId` is the same for every member of a site, so
|
|
57
|
+
it cannot tell an operator from a member. To decide whether to show an edit control for
|
|
58
|
+
a particular record, read that record: `entity.can_edit` is the backend's own answer.
|
|
59
|
+
|
|
60
|
+
**Signing up** — `useSignUp().signUp({ username, email, password })` answers
|
|
61
|
+
`{ status: 'verification_required', email }`, the same answer for an address already in
|
|
62
|
+
use. The account cannot sign in until the address is confirmed from the email the
|
|
63
|
+
backend sends; until then `signIn` fails with `error.kind === 'unverified'`. A username
|
|
64
|
+
already taken fails with status `409`.
|
|
65
|
+
|
|
66
|
+
**Password reset** — `usePasswordReset()`: `request({ email })`, then
|
|
67
|
+
`confirm({ token, new_password })` with the token the viewer was sent (and `code` when a
|
|
68
|
+
second factor is enrolled). **Second factor** — when `signIn` answers
|
|
69
|
+
`{ ok: false, challenge: { kind: 'totp' } }`, finish with `completeChallenge(code)`.
|
|
55
70
|
|
|
56
71
|
## Reading
|
|
57
72
|
|
|
58
73
|
```jsx
|
|
59
74
|
import { useRecords, useEntity } from '@uniweb/api'
|
|
60
75
|
|
|
61
|
-
const { status, records,
|
|
76
|
+
const { status, records, hasMore } = useRecords({ schema: '@acme/session', scope: 'mine' })
|
|
77
|
+
// records[i].brief — the entity's summary; records[i].uuid — its id
|
|
62
78
|
```
|
|
63
79
|
|
|
64
80
|
⭐ **`absent` and an empty `ready` are different answers.** `absent` means there is no
|
|
@@ -67,28 +83,50 @@ live source — no `api` service, or nobody signed in — so render your site's
|
|
|
67
83
|
Showing "nothing yet" for the first case tells a visitor their content is missing
|
|
68
84
|
when it is simply not being asked for.
|
|
69
85
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
**Whose records.** A member reads their own entities and what was shared with them;
|
|
87
|
+
other members' are private unless the site's service was set up to let members read
|
|
88
|
+
each other's. The site's operator reads everything. `scope: 'mine'` lists only the
|
|
89
|
+
viewer's own.
|
|
90
|
+
|
|
91
|
+
A record is a summary: `record.brief` holds the fields of the Model's brief section. It
|
|
92
|
+
carries no items — read the entity for those. `hasMore` is true when a page came back
|
|
93
|
+
full (`limit`, default 50); `matched` counts the records in this answer, not a total.
|
|
94
|
+
`all: true` reads the whole list in one request.
|
|
95
|
+
|
|
96
|
+
```jsx
|
|
97
|
+
const { status, entity } = useEntity({ schema: '@acme/lesson', uuid, via: course.uuid })
|
|
98
|
+
// entity.hydrated.items — the content: { id, section_id, data, … } each
|
|
99
|
+
// entity.hydrated.entity.brief — the summary
|
|
100
|
+
// entity.can_edit — whether this viewer may write to it
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Its `absent` covers both not-found and not-permitted, on purpose: render your paywall
|
|
104
|
+
or sign-in prompt on it and never say "deleted".
|
|
73
105
|
|
|
74
106
|
## Writing
|
|
75
107
|
|
|
108
|
+
An entity's content is **items, each in a section** of its Model. A section that holds
|
|
109
|
+
one item (the brief) takes one `create` and is `update`d after that; a many-item section
|
|
110
|
+
takes as many as you add.
|
|
111
|
+
|
|
76
112
|
```jsx
|
|
77
113
|
import { useEntityWriter } from '@uniweb/api'
|
|
78
114
|
|
|
79
|
-
const programme = useEntityWriter({ schema: '
|
|
115
|
+
const programme = useEntityWriter({ schema: '@acme/track', uuid: track.uuid })
|
|
80
116
|
|
|
81
117
|
await programme.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
|
|
82
|
-
await programme.update(
|
|
83
|
-
await programme.move(
|
|
84
|
-
await programme.remove(
|
|
118
|
+
await programme.update(item.id, { ...item.data, room: 'Hall A' })
|
|
119
|
+
await programme.move(item.id, { after: other.id })
|
|
120
|
+
await programme.remove(item.id)
|
|
85
121
|
await programme.batch([...]) // one transaction: all of them, or none
|
|
86
122
|
```
|
|
87
123
|
|
|
88
|
-
|
|
124
|
+
`item.id` is the `id` of an item on a read. Name a section as the Model does — the
|
|
125
|
+
package resolves it to what the backend's item route takes.
|
|
89
126
|
|
|
90
|
-
- **Concurrency is handled.** Every write carries the
|
|
91
|
-
response updates it. You
|
|
127
|
+
- **Concurrency is handled.** Every write carries the version of the item the viewer
|
|
128
|
+
last saw — from a read or from the previous write — and the response updates it. You
|
|
129
|
+
never touch a token.
|
|
92
130
|
- **`section` is required on `create`.** An entity has several, and a rule declared on
|
|
93
131
|
one — insert-only, say — does not reach an item that landed in another.
|
|
94
132
|
- **A successful write refreshes what it changed**, so a list you are showing reflects
|
|
@@ -97,9 +135,27 @@ Three things it does for you, and one it deliberately does not:
|
|
|
97
135
|
else changed the item first. A retry would *succeed*, by overwriting a change nobody
|
|
98
136
|
looked at — so what happens next is your application's decision, and usually it is
|
|
99
137
|
to tell the person.
|
|
138
|
+
- A write the Model's rules refuse — an insert-only section, a second item in a one-item
|
|
139
|
+
section — is an error of `kind: 'rule'`, not a conflict: nobody else is involved, and
|
|
140
|
+
the same write fails the same way again.
|
|
100
141
|
|
|
101
142
|
⚠️ `update` replaces the item's data whole. Spread what you are not editing.
|
|
102
143
|
|
|
144
|
+
To create an entity, name each item's section:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { createEntity } from '@uniweb/api/client'
|
|
148
|
+
|
|
149
|
+
await createEntity({ schema: '@acme/course', items: [{ section: 'course', data: { title: 'Intro' } }] })
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Errors
|
|
153
|
+
|
|
154
|
+
Every failure is an `ApiError` with a `kind`: `auth` (sign in), `unverified`, `absent`,
|
|
155
|
+
`forbidden` (`error.extensions.op` names what was refused), `invalid`
|
|
156
|
+
(`error.extensions.field` names a field the Model refused), `conflict`, `rule`,
|
|
157
|
+
`rate-limited`, `unavailable`, `disabled`. Branch on `kind`; `detail` is prose.
|
|
158
|
+
|
|
103
159
|
## The service on your machine
|
|
104
160
|
|
|
105
161
|
Building against a live service is slow and puts a shared database behind your
|
|
@@ -116,9 +172,26 @@ import { createMockBackend } from '@uniweb/api/mock'
|
|
|
116
172
|
|
|
117
173
|
export default createMockBackend({
|
|
118
174
|
seed: {
|
|
119
|
-
accounts: [
|
|
120
|
-
|
|
121
|
-
|
|
175
|
+
accounts: [
|
|
176
|
+
{ username: 'me', password: 'me', operator: true }, // the site's operator
|
|
177
|
+
{ username: 'member', password: 'member' },
|
|
178
|
+
],
|
|
179
|
+
schemas: {
|
|
180
|
+
'@acme/track': {
|
|
181
|
+
creatable_by: 'unit_members', // the operator only; 'any_user' is the default
|
|
182
|
+
sections: {
|
|
183
|
+
track: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
|
|
184
|
+
sessions: { kind: 'multi', fields: { title: { type: 'string', required: true } } },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
entities: [
|
|
189
|
+
{
|
|
190
|
+
uuid: '01926d5e-0000-7000-8000-000000000001', // a UUID, as on the wire
|
|
191
|
+
model: '@acme/track',
|
|
192
|
+
items: [{ section: 'track', data: { name: 'Main hall' } }],
|
|
193
|
+
},
|
|
194
|
+
],
|
|
122
195
|
},
|
|
123
196
|
}).fetch
|
|
124
197
|
```
|
|
@@ -126,9 +199,15 @@ export default createMockBackend({
|
|
|
126
199
|
`uniweb dev` mounts it at your `api:` address — same origin, so cookies behave as they
|
|
127
200
|
will in production, and your site's configuration is identical either way.
|
|
128
201
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
202
|
+
⭐ **The mock answers what the backend answers** — the same statuses, bodies and
|
|
203
|
+
refusals, for every route this package uses. It enforces what your schemas declare —
|
|
204
|
+
who may create entries, which sections are insert-only, the fields a section takes —
|
|
205
|
+
and it is as strict as the backend about requests, so a mistake fails here rather
|
|
206
|
+
than in front of a user. Members read and write their own entities and nothing of each
|
|
207
|
+
other's; the operator reads and writes everything (`memberFloor: 'read'` in the seed
|
|
208
|
+
models a service set up to let members read each other's). A new sign-up must be
|
|
209
|
+
verified: the link is in `mock.outbox` (the standalone server prints it). State is in
|
|
210
|
+
memory: restart to reset.
|
|
132
211
|
|
|
133
212
|
There is also a standalone server, for a frontend that is not a Uniweb site:
|
|
134
213
|
|
|
@@ -136,9 +215,6 @@ There is also a standalone server, for a frontend that is not a Uniweb site:
|
|
|
136
215
|
npx uniweb-api-mock --port 8787
|
|
137
216
|
```
|
|
138
217
|
|
|
139
|
-
⛔ **The mock is a fixture of what this package expects, not a model of any real
|
|
140
|
-
server.** Behaviour it happens to have is evidence about the mock and nothing else.
|
|
141
|
-
|
|
142
218
|
## Outside React
|
|
143
219
|
|
|
144
220
|
`@uniweb/api/client` carries the same operations as plain functions and imports no
|
package/bin/uniweb-api-mock.js
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* this when the frontend is not a Uniweb site, or when proxying to it.
|
|
8
8
|
*
|
|
9
9
|
* uniweb-api-mock [--port 8787] [--prefix /_api] [--seed ./seed.js]
|
|
10
|
+
*
|
|
11
|
+
* The site's `api` address for it is the URL it prints. Mail the backend would
|
|
12
|
+
* send — a sign-up's verification, a password reset — is printed here instead.
|
|
10
13
|
*/
|
|
11
14
|
import { createMockBackend } from '../src/mock/index.js'
|
|
12
15
|
import { serve } from '../src/mock/node.js'
|
|
@@ -21,10 +24,25 @@ const seedPath = flag('seed', null)
|
|
|
21
24
|
const seed = seedPath ? (await import(new URL(seedPath, `file://${process.cwd()}/`).href)).default : undefined
|
|
22
25
|
|
|
23
26
|
const mock = createMockBackend({ ...(seed ? { seed } : {}) })
|
|
24
|
-
const
|
|
27
|
+
const prefix = flag('prefix', '')
|
|
28
|
+
|
|
29
|
+
// Print each message the moment it is "sent", with what finishes it.
|
|
30
|
+
let printed = 0
|
|
31
|
+
const serving = {
|
|
32
|
+
fetch: async (request) => {
|
|
33
|
+
const response = await mock.fetch(request)
|
|
34
|
+
for (const message of mock.outbox.slice(printed)) {
|
|
35
|
+
console.log(` ✉ ${message.subject} → ${message.to}`)
|
|
36
|
+
console.log(message.verify ? ` verify: GET ${prefix}${message.verify}` : ` token: ${message.token}`)
|
|
37
|
+
}
|
|
38
|
+
printed = mock.outbox.length
|
|
39
|
+
return response
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
const server = await serve(serving, { port: Number(flag('port', 8787)), prefix })
|
|
25
43
|
|
|
26
44
|
console.log(`uniweb-api-mock listening on ${server.url}`)
|
|
27
|
-
console.log(` accounts: ${mock.store.accounts.map((a) => a.username).join(', ')}`)
|
|
45
|
+
console.log(` accounts: ${mock.store.accounts.map((a) => `${a.username}${a.operator ? ' (operator)' : ''}`).join(', ')}`)
|
|
28
46
|
console.log(' state is in memory — restart to reset')
|
|
29
47
|
|
|
30
48
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
package/package.json
CHANGED