@uniweb/api 0.3.7 → 0.4.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 +96 -23
- package/bin/uniweb-api-mock.js +20 -2
- package/package.json +1 -1
- package/src/client.js +237 -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 +18 -6
- package/src/http.js +11 -6
- package/src/ledger.js +87 -55
- package/src/mock/index.js +330 -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 +580 -295
- package/src/wire.js +314 -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,49 @@ 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.** The default `scope` is everything the viewer may read, and on a
|
|
87
|
+
site's `api` service **members can read each other's entities**. For "my submissions",
|
|
88
|
+
"my progress", pass `scope: 'mine'`.
|
|
89
|
+
|
|
90
|
+
A record is a summary: `record.brief` holds the fields of the Model's brief section. It
|
|
91
|
+
carries no items — read the entity for those. `hasMore` is true when a page came back
|
|
92
|
+
full (`limit`, default 50); `matched` counts the records in this answer, not a total.
|
|
93
|
+
`all: true` reads the whole list in one request.
|
|
94
|
+
|
|
95
|
+
```jsx
|
|
96
|
+
const { status, entity } = useEntity({ schema: '@acme/lesson', uuid, via: course.uuid })
|
|
97
|
+
// entity.hydrated.items — the content: { id, section_id, data, … } each
|
|
98
|
+
// entity.hydrated.entity.brief — the summary
|
|
99
|
+
// entity.can_edit — whether this viewer may write to it
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Its `absent` covers both not-found and not-permitted, on purpose: render your paywall
|
|
103
|
+
or sign-in prompt on it and never say "deleted".
|
|
73
104
|
|
|
74
105
|
## Writing
|
|
75
106
|
|
|
107
|
+
An entity's content is **items, each in a section** of its Model. A section that holds
|
|
108
|
+
one item (the brief) takes one `create` and is `update`d after that; a many-item section
|
|
109
|
+
takes as many as you add.
|
|
110
|
+
|
|
76
111
|
```jsx
|
|
77
112
|
import { useEntityWriter } from '@uniweb/api'
|
|
78
113
|
|
|
79
|
-
const programme = useEntityWriter({ schema: '
|
|
114
|
+
const programme = useEntityWriter({ schema: '@acme/track', uuid: track.uuid })
|
|
80
115
|
|
|
81
116
|
await programme.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
|
|
82
|
-
await programme.update(
|
|
83
|
-
await programme.move(
|
|
84
|
-
await programme.remove(
|
|
117
|
+
await programme.update(item.id, { ...item.data, room: 'Hall A' })
|
|
118
|
+
await programme.move(item.id, { after: other.id })
|
|
119
|
+
await programme.remove(item.id)
|
|
85
120
|
await programme.batch([...]) // one transaction: all of them, or none
|
|
86
121
|
```
|
|
87
122
|
|
|
88
|
-
|
|
123
|
+
`item.id` is the `id` of an item on a read. Name a section as the Model does — the
|
|
124
|
+
package resolves it to what the backend's item route takes.
|
|
89
125
|
|
|
90
|
-
- **Concurrency is handled.** Every write carries the
|
|
91
|
-
response updates it. You
|
|
126
|
+
- **Concurrency is handled.** Every write carries the version of the item the viewer
|
|
127
|
+
last saw — from a read or from the previous write — and the response updates it. You
|
|
128
|
+
never touch a token.
|
|
92
129
|
- **`section` is required on `create`.** An entity has several, and a rule declared on
|
|
93
130
|
one — insert-only, say — does not reach an item that landed in another.
|
|
94
131
|
- **A successful write refreshes what it changed**, so a list you are showing reflects
|
|
@@ -97,9 +134,27 @@ Three things it does for you, and one it deliberately does not:
|
|
|
97
134
|
else changed the item first. A retry would *succeed*, by overwriting a change nobody
|
|
98
135
|
looked at — so what happens next is your application's decision, and usually it is
|
|
99
136
|
to tell the person.
|
|
137
|
+
- A write the Model's rules refuse — an insert-only section, a second item in a one-item
|
|
138
|
+
section — is an error of `kind: 'rule'`, not a conflict: nobody else is involved, and
|
|
139
|
+
the same write fails the same way again.
|
|
100
140
|
|
|
101
141
|
⚠️ `update` replaces the item's data whole. Spread what you are not editing.
|
|
102
142
|
|
|
143
|
+
To create an entity, name each item's section:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { createEntity } from '@uniweb/api/client'
|
|
147
|
+
|
|
148
|
+
await createEntity({ schema: '@acme/course', items: [{ section: 'course', data: { title: 'Intro' } }] })
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Errors
|
|
152
|
+
|
|
153
|
+
Every failure is an `ApiError` with a `kind`: `auth` (sign in), `unverified`, `absent`,
|
|
154
|
+
`forbidden` (`error.extensions.op` names what was refused), `invalid`
|
|
155
|
+
(`error.extensions.field` names a field the Model refused), `conflict`, `rule`,
|
|
156
|
+
`rate-limited`, `unavailable`, `disabled`. Branch on `kind`; `detail` is prose.
|
|
157
|
+
|
|
103
158
|
## The service on your machine
|
|
104
159
|
|
|
105
160
|
Building against a live service is slow and puts a shared database behind your
|
|
@@ -116,9 +171,26 @@ import { createMockBackend } from '@uniweb/api/mock'
|
|
|
116
171
|
|
|
117
172
|
export default createMockBackend({
|
|
118
173
|
seed: {
|
|
119
|
-
accounts: [
|
|
120
|
-
|
|
121
|
-
|
|
174
|
+
accounts: [
|
|
175
|
+
{ username: 'me', password: 'me', operator: true }, // the site's operator
|
|
176
|
+
{ username: 'member', password: 'member' },
|
|
177
|
+
],
|
|
178
|
+
schemas: {
|
|
179
|
+
'@acme/track': {
|
|
180
|
+
creatable_by: 'unit_members', // the operator only; 'any_user' is the default
|
|
181
|
+
sections: {
|
|
182
|
+
track: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
|
|
183
|
+
sessions: { kind: 'multi', fields: { title: { type: 'string', required: true } } },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
entities: [
|
|
188
|
+
{
|
|
189
|
+
uuid: '01926d5e-0000-7000-8000-000000000001', // a UUID, as on the wire
|
|
190
|
+
model: '@acme/track',
|
|
191
|
+
items: [{ section: 'track', data: { name: 'Main hall' } }],
|
|
192
|
+
},
|
|
193
|
+
],
|
|
122
194
|
},
|
|
123
195
|
}).fetch
|
|
124
196
|
```
|
|
@@ -126,9 +198,13 @@ export default createMockBackend({
|
|
|
126
198
|
`uniweb dev` mounts it at your `api:` address — same origin, so cookies behave as they
|
|
127
199
|
will in production, and your site's configuration is identical either way.
|
|
128
200
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
201
|
+
⭐ **The mock answers what the backend answers** — the same statuses, bodies and
|
|
202
|
+
refusals, for every route this package uses. It enforces what your schemas declare —
|
|
203
|
+
who may create entries, which sections are insert-only, the fields a section takes —
|
|
204
|
+
and it is as strict as the backend about requests, so a mistake fails here rather
|
|
205
|
+
than in front of a user. Members read each other's entities and write their own; the
|
|
206
|
+
operator writes everything. A new sign-up must be verified: the link is in
|
|
207
|
+
`mock.outbox` (the standalone server prints it). State is in memory: restart to reset.
|
|
132
208
|
|
|
133
209
|
There is also a standalone server, for a frontend that is not a Uniweb site:
|
|
134
210
|
|
|
@@ -136,9 +212,6 @@ There is also a standalone server, for a frontend that is not a Uniweb site:
|
|
|
136
212
|
npx uniweb-api-mock --port 8787
|
|
137
213
|
```
|
|
138
214
|
|
|
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
215
|
## Outside React
|
|
143
216
|
|
|
144
217
|
`@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