Skip to content

Commit a0db1e1

Browse files
committed
docs(features): image-upload adapter contract + adopter requirements
1 parent a240280 commit a0db1e1

1 file changed

Lines changed: 219 additions & 0 deletions

File tree

docs/features/image-upload.md

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# Image upload
2+
3+
`view.image` is a presentation control for a `field.string` that stores an
4+
**opaque storage key**, not image bytes. The generated form renders a
5+
metadata-driven upload + crop control (`<ImageUpload>`) that hands the picked
6+
image to a consumer-supplied adapter and stores only the key the adapter
7+
returns. No image bytes ever cross the MetaObjects wire — the field, the
8+
generated Zod schema, and the REST payload all carry a plain string.
9+
10+
This is a **TS-web-only** feature. The metamodel vocabulary (`view.image` and
11+
its five attrs) is registered by the `metaobjects-ui-web` provider and applied
12+
only in TypeScript; the non-TS ports carry a byte-identical mirror of the spec
13+
file for drift parity but never apply the provider, and none of them ship an
14+
upload widget. A `field.string` authored with a `view.image` child is, to a
15+
Java/Kotlin/C#/Python port, just a plain string field.
16+
17+
## Authoring: `field.string` + `view.image`
18+
19+
Attach a `view.image` child to a `field.string` field. All five attrs are
20+
optional.
21+
22+
```jsonc
23+
{ "field.string": {
24+
"name": "coverKey",
25+
"@maxLength": 80,
26+
"children": [
27+
{ "view.image": {
28+
"@aspectRatio": 1.777,
29+
"@maxEdge": 2000,
30+
"@store": "photos",
31+
"@accept": ["image/jpeg", "image/png"],
32+
"@maxBytes": 10485760
33+
}}
34+
]
35+
}}
36+
```
37+
38+
Sigil-free YAML (see [yaml-authoring.md](yaml-authoring.md)):
39+
40+
```yaml
41+
field.string: coverKey
42+
maxLength: 80
43+
children:
44+
- view.image:
45+
aspectRatio: 1.777
46+
maxEdge: 2000
47+
store: photos
48+
accept: [image/jpeg, image/png]
49+
maxBytes: 10485760
50+
```
51+
52+
| Attr | Type | Purpose |
53+
|---|---|---|
54+
| `@aspectRatio` | double | Crop aspect ratio (width / height). Omit for a freeform crop. |
55+
| `@maxEdge` | int | Longest-edge bound in px for the re-encoded output. |
56+
| `@store` | string | Opaque storage-namespace hint passed to the upload adapter. This is a hint the adapter interprets — it is **not** infrastructure configuration, and MetaObjects never talks to a storage backend directly. |
57+
| `@accept` | string[] | Accepted MIME types. This is a client-side guard only — the server must re-enforce it. |
58+
| `@maxBytes` | int | Client-side size ceiling in bytes. Also a guard only — the server must re-enforce it. |
59+
60+
## What the generated form renders
61+
62+
The generated `<Entity>Form` dispatches a `view.image` field to `<ImageUpload>`
63+
wrapped in react-hook-form's `<Controller>` (a bound native `<input>` can't
64+
drive a file-upload control's value/onChange contract). The `Controller` and
65+
`ImageUpload` imports are emitted only when the entity has an image field.
66+
67+
```tsx
68+
<Controller
69+
name="coverKey"
70+
control={form.control}
71+
render={({ field }) => (
72+
<ImageUpload
73+
value={field.value as string | null}
74+
onChange={field.onChange}
75+
meta={{
76+
aspectRatio: 1.777,
77+
maxEdge: 2000,
78+
store: "photos",
79+
accept: ["image/jpeg", "image/png"],
80+
maxBytes: 10485760,
81+
}}
82+
/>
83+
)}
84+
/>
85+
```
86+
87+
`<ImageUpload>` renders the current image (via the adapter's `imageUrl`) plus
88+
Upload/Replace/Remove actions. Picking a file opens a lazy-loaded crop UI
89+
(`react-easy-crop`); saving the crop re-encodes the selection to a bounded
90+
JPEG on a `<canvas>` (which strips EXIF as a side effect — see
91+
[Expected backend contract](#expected-backend-contract)) and hands the blob to
92+
the adapter. Only the resulting key is written back through `onChange`.
93+
`<ImageUpload>` is also usable standalone, outside a generated form.
94+
95+
## Runtime wiring: the `ImageUploadAdapter` contract
96+
97+
`<ImageUpload>` has no knowledge of the storage backend, upload endpoint, or
98+
auth — it reads an adapter from React context. Wrap your app (or the relevant
99+
subtree) in `<ImageUploadAdapterProvider>`:
100+
101+
```tsx
102+
import { ImageUploadAdapterProvider } from "@metaobjectsdev/react";
103+
import type { ImageUploadAdapter } from "@metaobjectsdev/runtime-web";
104+
105+
const imageAdapter: ImageUploadAdapter = {
106+
async upload(blob, { store }) {
107+
const body = new FormData();
108+
body.set("file", blob, "image.jpg");
109+
if (store) body.set("store", store);
110+
const res = await fetch("/api/images", { method: "POST", body });
111+
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
112+
return (await res.json()) as { key: string };
113+
},
114+
imageUrl(key) {
115+
return `/api/images/${key}`;
116+
},
117+
};
118+
119+
export function App() {
120+
return (
121+
<ImageUploadAdapterProvider value={imageAdapter}>
122+
{/* ... your routes / generated forms ... */}
123+
</ImageUploadAdapterProvider>
124+
);
125+
}
126+
```
127+
128+
The contract, exported as a type from `@metaobjectsdev/runtime-web`:
129+
130+
```ts
131+
interface ImageUploadAdapter {
132+
upload(blob: Blob, opts: { store?: string }): Promise<{ key: string }>;
133+
imageUrl(key: string): string;
134+
}
135+
```
136+
137+
The library ships **no concrete adapter**`upload`/`imageUrl` are the seam a
138+
consumer implements against its own backend. `useImageUploadAdapter()` reads
139+
the adapter from context and throws a descriptive error if called outside a
140+
`<ImageUploadAdapterProvider>`.
141+
142+
## Expected backend contract
143+
144+
The adapter is a thin client for a backend you own. That backend is expected
145+
to expose:
146+
147+
1. **`POST` (multipart) → `{ key }`** — accepts the uploaded blob, stores it
148+
under an object key (optionally namespaced by the `store` hint), and
149+
returns that opaque key. The generated field stores exactly this string.
150+
2. **`GET <key>` → bytes** with an immutable cache header (e.g.
151+
`Cache-Control: public, max-age=31536000, immutable`) — a stored image's
152+
bytes never change in place; a replace produces a new key, so the old key's
153+
response can be cached forever.
154+
3. **A server-side EXIF re-check.** The client-side crop/upload path
155+
(`cropToBlob` / `reencodeJpeg` in `@metaobjectsdev/runtime-web`) re-encodes
156+
the selection through a `<canvas>`, which strips EXIF (orientation, GPS,
157+
camera metadata) as a side effect of the redraw. That is a client
158+
convenience, not a security boundary — a malicious or nonstandard client
159+
can send anything. The backend must independently strip or re-check EXIF
160+
(and re-validate MIME type / size) on every upload; never trust the client
161+
re-encode.
162+
163+
MetaObjects does not generate or ship this backend — it is documented so an
164+
adapter can be implemented against any storage/serving stack.
165+
166+
## CSP requirement: `img-src` must allow `blob:`
167+
168+
`<ImageUpload>`'s local preview and the crop UI (`react-easy-crop`) render the
169+
picked file as a `blob:` object URL (`URL.createObjectURL`) before anything is
170+
uploaded. If your app sets a Content-Security-Policy, its `img-src` directive
171+
must include `blob:` or the local preview/crop will be blocked:
172+
173+
```
174+
Content-Security-Policy: img-src 'self' blob: https://your-image-host;
175+
```
176+
177+
## Styling: opt-in `form.css`
178+
179+
Default styling for the generated form controls and `<ImageUpload>` ships as
180+
an optional CSS file:
181+
182+
```ts
183+
import "@metaobjectsdev/react/form.css";
184+
```
185+
186+
It targets stable class names (`metaobjects-form`, `metaobjects-field-*`,
187+
`metaobjects-image-*`, `metaobjects-form-submit`, …) at single-class
188+
specificity so a host app's reset styles win, and resolves colors through
189+
`--mo-*` CSS custom properties with fallback hex values — themable with zero
190+
configuration, override any subset.
191+
192+
`react-easy-crop` is an **optional, lazy-loaded peer dependency** — it's only
193+
imported (via `React.lazy`) when `<ImageUpload>` actually renders the crop UI,
194+
so consumers without a `view.image` field never pay for it. Install it only if
195+
you author one.
196+
197+
## Storage & wire contract
198+
199+
- The field is a plain `field.string` (e.g. `@maxLength 80`); only the opaque
200+
key is stored in the column and travels on the wire — request bodies,
201+
response bodies, and the generated Zod schema all see a string, exactly
202+
like any other string field.
203+
- Image **bytes** flow app → adapter → backend only, over the separate
204+
multipart/GET endpoints described above. They never pass through a
205+
MetaObjects-generated route, schema, or the entity's own REST payload.
206+
- Replacing an image is a new upload producing a new key; nothing is mutated
207+
in place.
208+
- Because the stored value is an ordinary string, no other port needs image
209+
logic to interoperate with this field — a Java/Kotlin/C#/Python service
210+
reading or writing the same entity just sees a string column.
211+
212+
## See also
213+
214+
- [field-types.md](field-types.md)`field.string` and the common field
215+
attribute reference
216+
- [../ports/typescript-client.md](../ports/typescript-client.md) — the
217+
broader React/TanStack client surface (`useEntityForm`, `<CurrencyInput>`,
218+
generated hooks/grids) that `<ImageUpload>` slots into
219+
- [yaml-authoring.md](yaml-authoring.md) — sigil-free YAML authoring front-end

0 commit comments

Comments
 (0)