field_struct 0.15.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.
- checksums.yaml +7 -0
- data/AGENTS.md +58 -0
- data/CHANGELOG.md +653 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +645 -0
- data/UPGRADING.md +743 -0
- data/USAGE.md +820 -0
- data/docs/getting_started.md +337 -0
- data/lib/field_struct/base.rb +1316 -0
- data/lib/field_struct/error.rb +48 -0
- data/lib/field_struct/errors.rb +103 -0
- data/lib/field_struct/field.rb +179 -0
- data/lib/field_struct/metadata.rb +331 -0
- data/lib/field_struct/model_name.rb +48 -0
- data/lib/field_struct/rbs.rb +299 -0
- data/lib/field_struct/registry.rb +85 -0
- data/lib/field_struct/scaffold.rb +331 -0
- data/lib/field_struct/types/array.rb +156 -0
- data/lib/field_struct/types/base.rb +162 -0
- data/lib/field_struct/types/big_decimal.rb +78 -0
- data/lib/field_struct/types/binary.rb +38 -0
- data/lib/field_struct/types/boolean.rb +126 -0
- data/lib/field_struct/types/date.rb +158 -0
- data/lib/field_struct/types/datetime.rb +163 -0
- data/lib/field_struct/types/email.rb +49 -0
- data/lib/field_struct/types/float.rb +80 -0
- data/lib/field_struct/types/immutable_string.rb +18 -0
- data/lib/field_struct/types/integer.rb +133 -0
- data/lib/field_struct/types/nested.rb +82 -0
- data/lib/field_struct/types/preset_resolver.rb +37 -0
- data/lib/field_struct/types/rfc3339_format.rb +197 -0
- data/lib/field_struct/types/string.rb +73 -0
- data/lib/field_struct/types/symbol.rb +38 -0
- data/lib/field_struct/types/temporal_parser.rb +93 -0
- data/lib/field_struct/types/time.rb +168 -0
- data/lib/field_struct/types/time_format_resolver.rb +54 -0
- data/lib/field_struct/types/union.rb +96 -0
- data/lib/field_struct/types/url.rb +46 -0
- data/lib/field_struct/types/uuid.rb +46 -0
- data/lib/field_struct/types/value.rb +24 -0
- data/lib/field_struct/types/zone_option.rb +267 -0
- data/lib/field_struct/version.rb +7 -0
- data/lib/field_struct.rb +159 -0
- data/sig/field_struct.rbs +2845 -0
- data/skills/field-struct/SKILL.md +183 -0
- metadata +139 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# Getting started with FieldStruct
|
|
2
|
+
|
|
3
|
+
How to use FieldStruct in a repo — productively, in about ten minutes. FieldStruct
|
|
4
|
+
builds typed POROs: you declare fields, and get coerced, typed accessors plus
|
|
5
|
+
presence/format validation. Construction and validity are **separate** — you can
|
|
6
|
+
build an invalid instance and ask it `valid?`.
|
|
7
|
+
|
|
8
|
+
This is the task-oriented adoption guide. For the exhaustive list of types and
|
|
9
|
+
options see [`USAGE.md`](../USAGE.md); for the feature tour see
|
|
10
|
+
[`README.md`](../README.md).
|
|
11
|
+
|
|
12
|
+
## At a glance — task → API
|
|
13
|
+
|
|
14
|
+
| You want to… | Do this |
|
|
15
|
+
|---|---|
|
|
16
|
+
| Declare a presence-checked field | `required :name, :string` |
|
|
17
|
+
| Declare a typed but optional field | `optional :age, :integer` |
|
|
18
|
+
| Parse an inbound webhook / JSON body | `Payload.from_json(json_string)` |
|
|
19
|
+
| Map external camelCase keys | `serialize :json, snake_name: 'camelName'` |
|
|
20
|
+
| Render to JSON | `struct.to_json` / `struct.as_json` |
|
|
21
|
+
| Nest a struct | `required :address, Address` |
|
|
22
|
+
| A list of things | `required :items, :array, of: LineItem` |
|
|
23
|
+
| Bootstrap a model from a sample payload | `FieldStruct::Scaffold.from_json(json)` |
|
|
24
|
+
| See an existing model's shape | `pp Klass.metadata.to_h` |
|
|
25
|
+
| Generate RBS for your models | `FieldStruct::RBS.generate(Klass)` |
|
|
26
|
+
| Change an immutable/frozen instance | `record.with(status: 'paid')` (returns a copy) |
|
|
27
|
+
| Refuse loose input on a money/id field | `required :cents, :integer, strict: true` |
|
|
28
|
+
| Read zone-less timestamps at a fixed offset | `optional :at, :time, zone: '-06:00'` |
|
|
29
|
+
| Keep card data out of `inspect` / logs | `required :pan, :string, sensitive: true` |
|
|
30
|
+
| Options for each array element | `optional :ids, :array, of: :integer, element: {strict: true}` |
|
|
31
|
+
| Reject unexpected keys at an API boundary | `unknown_attributes :raise` (on each struct) |
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Gemfile
|
|
37
|
+
gem 'field_struct'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require 'field_struct' # already loaded for you in a Bundler-managed app
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Part 1 — Any Ruby project
|
|
47
|
+
|
|
48
|
+
### Your first model
|
|
49
|
+
|
|
50
|
+
<!-- doctest -->
|
|
51
|
+
```ruby
|
|
52
|
+
class User < FieldStruct::Base
|
|
53
|
+
required :name, :string
|
|
54
|
+
optional :age, :integer
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
u = User.new(name: 'Alice', age: '30') # Symbol or String keys both work
|
|
58
|
+
u.age # => 30
|
|
59
|
+
u.valid? # => true
|
|
60
|
+
u.attributes # => { name: "Alice", age: 30 }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`required` presence-checks the field; `optional` (and bare `field`) don't. The value
|
|
64
|
+
is coerced through the type — note `'30'` became `30`.
|
|
65
|
+
|
|
66
|
+
### Start fast from an existing payload
|
|
67
|
+
|
|
68
|
+
Modeling an external API or webhook? Don't hand-write the class — scaffold a starter
|
|
69
|
+
from a sample and refine it. It's a **prototype you finish**, not a finished model.
|
|
70
|
+
|
|
71
|
+
<!-- doctest -->
|
|
72
|
+
```ruby
|
|
73
|
+
src = FieldStruct::Scaffold.from_json('{"id": 1, "active": true, "ref": "00421"}')
|
|
74
|
+
src.include?('required :id, :integer') # => true
|
|
75
|
+
src.include?('required :active, :boolean') # => true
|
|
76
|
+
src.include?('required :ref, :string') # => true
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
It's conservative on purpose: real JSON booleans/numbers get typed; **strings stay
|
|
80
|
+
`:string`** (a numeric-looking `"00421"` must not silently become an Integer), with a
|
|
81
|
+
trailing `# …` comment nudging you. Objects become nested classes, arrays become
|
|
82
|
+
`:array, of: …`. Pass an **array of same-shape objects** to get more signal — empty
|
|
83
|
+
fields resolve from samples that have them, presence drives `required`/`optional`, and
|
|
84
|
+
small repeated vocabularies surface as `# values: [...] — enum?` hints.
|
|
85
|
+
|
|
86
|
+
Then you add the things no tool can infer: enums, `required`, `description:`, formats,
|
|
87
|
+
and final scalar types — the comments point at where.
|
|
88
|
+
|
|
89
|
+
### Where files go
|
|
90
|
+
|
|
91
|
+
One class per file; name the file after the class. In a plain Ruby project, put them
|
|
92
|
+
under `lib/` and `require` them (FieldStruct uses explicit requires, no autoloading):
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
lib/
|
|
96
|
+
payments/
|
|
97
|
+
webhook_payload.rb # class Payments::WebhookPayload < FieldStruct::Base
|
|
98
|
+
line_item.rb # class Payments::LineItem < FieldStruct::Base
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Declare a nested/element class **before** the class that references it.
|
|
102
|
+
|
|
103
|
+
### Reading and writing JSON
|
|
104
|
+
|
|
105
|
+
`serialize :json` maps internal field names to external keys; `from_json` reverse-maps
|
|
106
|
+
on the way in, `as_json`/`to_json` forward-map on the way out.
|
|
107
|
+
|
|
108
|
+
<!-- doctest -->
|
|
109
|
+
```ruby
|
|
110
|
+
class Charge < FieldStruct::Base
|
|
111
|
+
required :amount, :integer
|
|
112
|
+
optional :note, :string
|
|
113
|
+
serialize :json, amount: 'amountCents' # declare fields first, then serialize
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
c = Charge.from_json('{"amountCents": 500}')
|
|
117
|
+
c.amount # => 500
|
|
118
|
+
c.valid? # => true
|
|
119
|
+
c.to_json # => '{"amountCents":500,"note":null}'
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Everything round-trips except `:value` (no type info to restore). JSON is handled with
|
|
123
|
+
Oj.
|
|
124
|
+
|
|
125
|
+
### A complete example — nesting, arrays, coercion
|
|
126
|
+
|
|
127
|
+
<!-- doctest -->
|
|
128
|
+
```ruby
|
|
129
|
+
class LineItem < FieldStruct::Base
|
|
130
|
+
required :sku, :string
|
|
131
|
+
required :qty, :integer
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class Order < FieldStruct::Base
|
|
135
|
+
required :id, :string
|
|
136
|
+
required :items, :array, of: LineItem
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
order = Order.from_json('{"id":"A1","items":[{"sku":"X","qty":"2"}]}')
|
|
140
|
+
order.id # => "A1"
|
|
141
|
+
order.items.first.qty # => 2 (coerced from "2")
|
|
142
|
+
order.valid? # => true
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Nested structs and arrays-of-structs coerce and validate recursively — an `Order` with
|
|
146
|
+
an invalid `LineItem` is itself invalid.
|
|
147
|
+
|
|
148
|
+
### Introspecting a model
|
|
149
|
+
|
|
150
|
+
`Klass.metadata.to_h` is a copy-pasteable view of a model's shape — handy for *seeing*
|
|
151
|
+
a class without opening its source.
|
|
152
|
+
|
|
153
|
+
<!-- doctest -->
|
|
154
|
+
```ruby
|
|
155
|
+
class Widget < FieldStruct::Base
|
|
156
|
+
required :name, :string
|
|
157
|
+
optional :size, :integer
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
Widget.metadata.to_h[:name][:type] # => "String"
|
|
161
|
+
Widget.metadata.to_h[:size][:required] # => false
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### When things are invalid — errors
|
|
165
|
+
|
|
166
|
+
A FieldStruct always constructs; check `valid?` and read `errors`.
|
|
167
|
+
|
|
168
|
+
<!-- doctest -->
|
|
169
|
+
```ruby
|
|
170
|
+
class Account < FieldStruct::Base
|
|
171
|
+
required :email, :string
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
a = Account.new(email: ' ') # whitespace-only counts as missing
|
|
175
|
+
a.valid? # => false
|
|
176
|
+
a.errors[:email] # => ["is required"]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
What the messages mean and what to do:
|
|
180
|
+
|
|
181
|
+
| You see | It means | Do |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `errors[:x] == ["is required"]` | required field is missing/blank (for `:string`, blank = nil/empty/whitespace) | provide a value, or make it `optional` |
|
|
184
|
+
| `["is invalid"]` | present but fails `format:`/`enum:`/`in:`, or a nested struct/array element is invalid | check the value against the constraint |
|
|
185
|
+
| `["could not be coerced: …"]` | coercion raised; under the default `:keep_raw` the raw value is kept | fix the input or change the field's type |
|
|
186
|
+
| `FieldStruct::UnknownAttributeError` | input had an undeclared key and the class set `unknown_attributes :raise` | declare the field, or drop the key |
|
|
187
|
+
| `FieldStruct::CoercionError` | coercion failed and the field/class uses `coercion_policy :raise` | fix the input or relax the policy |
|
|
188
|
+
|
|
189
|
+
### Common mistakes
|
|
190
|
+
|
|
191
|
+
1. **Setters are permissive — don't "fix" them.** `user.age = "30"` is *valid*; the
|
|
192
|
+
type coerces. Readers are typed; writers accept anything coercible.
|
|
193
|
+
2. **`required` does not guarantee non-nil at runtime.** A constructed-but-invalid
|
|
194
|
+
instance (or a `:replace` coercion failure) can hold `nil`. Gate on `valid?`.
|
|
195
|
+
3. **Whitespace-only strings are "missing"** for `:string` and its subtypes
|
|
196
|
+
(`:uuid`/`:url`/`:email`/`:immutable_string`). `:binary` is the exception.
|
|
197
|
+
4. **`:array` needs `of:`; `:union` needs `of: [...]`** with ≥2 members (else
|
|
198
|
+
`ArgumentError` at class-definition time).
|
|
199
|
+
5. **Option scoping** (misuse raises at declaration): `format:` → string-shaped +
|
|
200
|
+
date/time/datetime; `enum:` → string/symbol; `in:` → numeric/temporal; `round:` →
|
|
201
|
+
float/decimal; `base:` → integer; `values:` → boolean.
|
|
202
|
+
6. **`immutable!` / `frozen!` are one-way.** They inherit to subclasses and can't be
|
|
203
|
+
un-set on a child (there's no `mutable!`). If only some subclasses should be
|
|
204
|
+
immutable, declare it on those leaf classes — not a shared parent. (`coercion_policy`
|
|
205
|
+
and `unknown_attributes` *can* be overridden to any value on a child.)
|
|
206
|
+
|
|
207
|
+
### Testing your models
|
|
208
|
+
|
|
209
|
+
Models are plain objects — test them directly:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
RSpec.describe Order do
|
|
213
|
+
it 'coerces and validates a payload' do
|
|
214
|
+
order = described_class.from_json(fixture('order.json'))
|
|
215
|
+
expect(order).to be_valid
|
|
216
|
+
expect(order.items.first.qty).to eq(2)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Agent toolbox
|
|
222
|
+
|
|
223
|
+
If you're an AI assistant working in this repo, reach for the gem's machine
|
|
224
|
+
affordances instead of guessing:
|
|
225
|
+
|
|
226
|
+
- `pp Klass.metadata.to_h` — *see* a model's fields/types without reading its source.
|
|
227
|
+
- `FieldStruct::Scaffold.from_json(json)` — bootstrap a model from a sample payload.
|
|
228
|
+
- `FieldStruct::RBS.generate(Klass)` — emit RBS for a model's accessors (Steep/Solargraph).
|
|
229
|
+
- The gem ships [`USAGE.md`](../USAGE.md) (full reference) and a Claude Code skill
|
|
230
|
+
(`skills/field-struct/SKILL.md`) — both are in `bundle show field_struct`.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Part 2 — Rails
|
|
235
|
+
|
|
236
|
+
### Where FieldStruct fits (and doesn't)
|
|
237
|
+
|
|
238
|
+
Use it for **API/webhook payloads**, service-object inputs/outputs, typed config, and
|
|
239
|
+
value objects. It is **not** a form object and **not** an ActiveRecord model — it
|
|
240
|
+
doesn't persist or query. Its ActiveModel-shaped surface (`valid?`, `errors`,
|
|
241
|
+
`model_name`, `to_model`) means error rendering and `*_path` helpers work, but treat it
|
|
242
|
+
as a typed value object, not a form backer.
|
|
243
|
+
|
|
244
|
+
### File placement (Zeitwerk)
|
|
245
|
+
|
|
246
|
+
Put your structs in an autoloaded path so Rails loads them by name. A common choice is
|
|
247
|
+
`app/structs/`:
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
app/structs/
|
|
251
|
+
payments/
|
|
252
|
+
webhook_payload.rb # class Payments::WebhookPayload < FieldStruct::Base
|
|
253
|
+
line_item.rb # class Payments::LineItem < FieldStruct::Base
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The path mirrors the constant: `app/structs/payments/line_item.rb` ↔
|
|
257
|
+
`Payments::LineItem`. Zeitwerk autoloads them — no `require` needed in app code. (If you
|
|
258
|
+
reference structs at boot, rely on eager loading in production.)
|
|
259
|
+
|
|
260
|
+
### Inbound — parsing a webhook or API request
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
class WebhooksController < ApplicationController
|
|
264
|
+
def nmi
|
|
265
|
+
payload = Payments::WebhookPayload.from_json(request.raw_post)
|
|
266
|
+
|
|
267
|
+
if payload.valid?
|
|
268
|
+
ProcessTransaction.call(payload)
|
|
269
|
+
head :ok
|
|
270
|
+
else
|
|
271
|
+
Rails.logger.warn(payload.errors.to_h)
|
|
272
|
+
head :unprocessable_entity
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`from_json` applies any `serialize :json` mapping, so a `firstName` JSON key lands in a
|
|
279
|
+
`first_name` field. Build the struct, check `valid?`, branch.
|
|
280
|
+
|
|
281
|
+
### Outbound — rendering JSON
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
def show
|
|
285
|
+
charge = build_charge
|
|
286
|
+
render json: charge # uses as_json/to_json, applying the :json mapping
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Scaffolding a model for a new integration
|
|
291
|
+
|
|
292
|
+
The fastest way to onboard a new provider:
|
|
293
|
+
|
|
294
|
+
1. Capture a sample payload (or several) into `spec/fixtures/` (or a console).
|
|
295
|
+
2. Generate a starter class and drop it into `app/structs/`:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
# rails runner / console
|
|
299
|
+
src = FieldStruct::Scaffold.from_json(File.read('spec/fixtures/nmi_sale.json'),
|
|
300
|
+
class_name: 'Payments::NmiSale')
|
|
301
|
+
File.write('app/structs/payments/nmi_sale.rb', src)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
3. Refine: flip the `# enum?` hints into `enum:`, set the real `required` fields, add
|
|
305
|
+
`description:`, and replace numeric-string `:string`s with `:integer`/`:big_decimal`
|
|
306
|
+
where they're truly numeric (leave IDs and codes as `:string`).
|
|
307
|
+
4. Add a spec that loads the fixture and asserts `valid?`.
|
|
308
|
+
|
|
309
|
+
Feed `Scaffold.from_json` an **array** of captured payloads for sharper
|
|
310
|
+
required/optional and enum inference.
|
|
311
|
+
|
|
312
|
+
### Types for Steep / Solargraph (RBS)
|
|
313
|
+
|
|
314
|
+
If your app runs a type checker, generate RBS for your structs' accessors (the dynamic
|
|
315
|
+
`field` methods Sord can't see):
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
# lib/tasks/field_struct_rbs.rake
|
|
319
|
+
namespace :field_struct do
|
|
320
|
+
task rbs: :environment do
|
|
321
|
+
classes = [Payments::WebhookPayload, Payments::LineItem]
|
|
322
|
+
File.write('sig/field_structs.rbs',
|
|
323
|
+
classes.map { |k| FieldStruct::RBS.generate(k) }.join("\n"))
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Generate RBS for nested/element classes too, so references like `::Payments::LineItem`
|
|
329
|
+
resolve.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## Where to go next
|
|
334
|
+
|
|
335
|
+
- [`USAGE.md`](../USAGE.md) — every type, option, and macro on one page.
|
|
336
|
+
- [`README.md`](../README.md) — the feature tour.
|
|
337
|
+
- `skills/field-struct/SKILL.md` — the bundled Claude Code skill.
|