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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +58 -0
  3. data/CHANGELOG.md +653 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/README.md +645 -0
  6. data/UPGRADING.md +743 -0
  7. data/USAGE.md +820 -0
  8. data/docs/getting_started.md +337 -0
  9. data/lib/field_struct/base.rb +1316 -0
  10. data/lib/field_struct/error.rb +48 -0
  11. data/lib/field_struct/errors.rb +103 -0
  12. data/lib/field_struct/field.rb +179 -0
  13. data/lib/field_struct/metadata.rb +331 -0
  14. data/lib/field_struct/model_name.rb +48 -0
  15. data/lib/field_struct/rbs.rb +299 -0
  16. data/lib/field_struct/registry.rb +85 -0
  17. data/lib/field_struct/scaffold.rb +331 -0
  18. data/lib/field_struct/types/array.rb +156 -0
  19. data/lib/field_struct/types/base.rb +162 -0
  20. data/lib/field_struct/types/big_decimal.rb +78 -0
  21. data/lib/field_struct/types/binary.rb +38 -0
  22. data/lib/field_struct/types/boolean.rb +126 -0
  23. data/lib/field_struct/types/date.rb +158 -0
  24. data/lib/field_struct/types/datetime.rb +163 -0
  25. data/lib/field_struct/types/email.rb +49 -0
  26. data/lib/field_struct/types/float.rb +80 -0
  27. data/lib/field_struct/types/immutable_string.rb +18 -0
  28. data/lib/field_struct/types/integer.rb +133 -0
  29. data/lib/field_struct/types/nested.rb +82 -0
  30. data/lib/field_struct/types/preset_resolver.rb +37 -0
  31. data/lib/field_struct/types/rfc3339_format.rb +197 -0
  32. data/lib/field_struct/types/string.rb +73 -0
  33. data/lib/field_struct/types/symbol.rb +38 -0
  34. data/lib/field_struct/types/temporal_parser.rb +93 -0
  35. data/lib/field_struct/types/time.rb +168 -0
  36. data/lib/field_struct/types/time_format_resolver.rb +54 -0
  37. data/lib/field_struct/types/union.rb +96 -0
  38. data/lib/field_struct/types/url.rb +46 -0
  39. data/lib/field_struct/types/uuid.rb +46 -0
  40. data/lib/field_struct/types/value.rb +24 -0
  41. data/lib/field_struct/types/zone_option.rb +267 -0
  42. data/lib/field_struct/version.rb +7 -0
  43. data/lib/field_struct.rb +159 -0
  44. data/sig/field_struct.rbs +2845 -0
  45. data/skills/field-struct/SKILL.md +183 -0
  46. 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.