xero-kiwi 0.5.0 → 0.5.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b980ba3e8f03590d2ca887887d8509268bf87df52b882eef4730a9ce1512250c
4
- data.tar.gz: bdd4952e2b7d1fd3af201f2446d17d5cd7f2bfe86e58ee9002f4115cc5624240
3
+ metadata.gz: fb7181193042ba51598251bc4bee70c0e84ae0333216d94838e996f813b05770
4
+ data.tar.gz: 7103cdb42e4cb742cbafa0eae6c807f7958626751dbdd1cd86aa14fbbe4939a8
5
5
  SHA512:
6
- metadata.gz: e7086fef7948e63b5d9437a164a241f819e5a96beead2eb2701989613e8b95f643d805be209a63b98ab3dcd278c7e81f46165de054f1fd865318abb82a13e0e9
7
- data.tar.gz: 94c08345afefa1b130634ab9d643052d14a24ba5200ae60a7b324344706d71465e9cecb6ce30e183876dafdf7bde0fcdbbaea487869ea99073bb388f2a7f8ab2
6
+ metadata.gz: f5a369e373c45e9d4343666eb59e2adbf16a1620efe57052b89f655be7d09bc63c30d120c7a47dc873e5ed0ac1f6af5fb2b23c4b71847819ac15c9f3c2e0f5a3
7
+ data.tar.gz: 51ac21d3ace42a9ebefaa03ee2fdf924a6dd9549eb41d053901f0a2c6d115147e35a9f90e77438241937ca2171fdbf6a295792bfbef4528e209b5211f1d598b4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.2] - 2026-09-27
4
+
5
+ ### Documentation
6
+
7
+ - Documented that `Resource#raw` returns nil on nested objects (line items, addresses, contact persons, payment terms) even with `retain_raw: true`, and that the enclosing resource's hash is the access path — `org.raw["Addresses"]` rather than `org.addresses.map(&:raw)`. The behaviour was deliberate and explained in a comment on `from_response`, but the `#raw` docstring never mentioned it and the failure is a silent nil, so a consumer mapped over a nested collection and was one step from storing `[null]`.
8
+ - Added a written contract for `raw` in `docs/client.md`: it is the parsed JSON with only top-level keys stringified, kiwi never normalises it (absent keys stay absent rather than becoming `[]`), unmodelled keys are preserved, and the freeze is shallow — the hash is frozen, the structures inside it are not. Specs pin all four, plus the nested-nil behaviour. The previous "is frozen" example overstated the guarantee and has been replaced.
9
+ - `llms.txt` is now generated alongside `llms-full.txt` from a single manifest in `tasks/llms.rb`, rather than hand-maintained. It had not been touched since the initial commit and was missing four releases of material — no `querying.md`, no `throttling.md`, no `tracking-category.md`, and no mention of `Page`, `RateLimit`, the throttle limiters or the tracking classes. `rake llms:check` now also fails when a doc exists under `docs/` that the manifest does not list, which is the half that was missing: the freshness check could not notice a doc that was never indexed in the first place.
10
+
11
+ ## [0.5.1] - 2026-09-26
12
+
13
+ ### Documentation
14
+
15
+ - Pinned down the scope of `Resource#raw`, which was ambiguous enough to mislead a real consumer. It holds the resource's own item hash — `contact.raw` has no `"Contacts"` key — and it is the JSON representation specifically. Xero's XML representation nests differently (no arrays, so one child parses to a Hash and several to an Array), so `raw` cannot reproduce an XML-derived shape. `docs/client.md` gains a short migration section covering what that means for anyone replacing a client that sent `Accept: text/xml` and has stored payloads. Specs added for both the envelope scope and the PascalCase/snake_case split. The migration section sorts readers into the two piles that behave differently: ones that dig the XML-only structure and must change, and ones that already normalise (`[value].flatten.compact`) where only the writer changes and stored rows stay readable.
16
+
3
17
  ## [0.5.0] - 2026-09-26
4
18
 
5
19
  The sync-support release: everything needed to drive a full-tenant sync through kiwi rather than around it.
data/Rakefile CHANGED
@@ -9,76 +9,35 @@ require "rubocop/rake_task"
9
9
 
10
10
  RuboCop::RakeTask.new
11
11
 
12
- # Files included in llms-full.txt, in reading order. Keep README first, then
13
- # the docs in the same order the README's table of contents lists them.
14
- LLMS_SOURCE_FILES = %w[
15
- README.md
16
- docs/getting-started.md
17
- docs/client.md
18
- docs/oauth.md
19
- docs/tokens.md
20
- docs/connections.md
21
- docs/accounting/contact.md
22
- docs/accounting/contact-group.md
23
- docs/accounting/organisation.md
24
- docs/accounting/user.md
25
- docs/accounting/credit-note.md
26
- docs/accounting/invoice.md
27
- docs/accounting/payment.md
28
- docs/accounting/overpayment.md
29
- docs/accounting/prepayment.md
30
- docs/accounting/branding-theme.md
31
- docs/accounting/tracking-category.md
32
- docs/accounting/address.md
33
- docs/accounting/phone.md
34
- docs/accounting/external-link.md
35
- docs/accounting/payment-terms.md
36
- docs/errors.md
37
- docs/retries-and-rate-limits.md
38
- docs/throttling.md
39
- docs/querying.md
40
- ].freeze
41
-
42
- LLMS_FULL_PATH = "llms-full.txt"
43
-
44
- # Builds the llms-full.txt content from LLMS_SOURCE_FILES. Pure function:
45
- # returns a String, doesn't touch the filesystem. Both `llms:build` and
46
- # `llms:check` use this so the two tasks can never disagree about the
47
- # expected output.
48
- def build_llms_full
49
- out = String.new(encoding: "UTF-8")
50
- out << "# Xero Kiwi — full documentation\n\n"
51
- out << "This file is the complete documentation for the Xero Kiwi gem (a Ruby wrapper for the Xero Accounting API), assembled into a single document for LLM consumption. It contains the README and every doc in the docs/ folder, in reading order.\n\n"
52
- out << "For the curated index version, see llms.txt in the same directory.\n\n"
53
- out << "Source: https://github.com/douglasgreyling/xero-kiwi\n\n"
54
- LLMS_SOURCE_FILES.each { |path| append_file_block(out, path) }
55
- out
56
- end
57
-
58
- def append_file_block(out, path)
59
- separator = "=" * 80
60
- out << "\n" << separator << "\n"
61
- out << "FILE: #{path}\n"
62
- out << separator << "\n\n"
63
- out << File.read(path, encoding: "UTF-8") << "\n"
64
- end
12
+ require_relative "tasks/llms"
65
13
 
66
14
  namespace :llms do
67
- desc "Regenerate llms-full.txt from README and docs/"
15
+ desc "Regenerate llms.txt and llms-full.txt from README and docs/"
68
16
  task :build do
69
- File.write(LLMS_FULL_PATH, build_llms_full)
70
- puts "Wrote #{LLMS_FULL_PATH} (#{File.size(LLMS_FULL_PATH)} bytes, #{LLMS_SOURCE_FILES.size} source files)"
17
+ Llms.outputs.each do |path, content|
18
+ File.write(path, content)
19
+ puts "Wrote #{path} (#{File.size(path)} bytes)"
20
+ end
71
21
  end
72
22
 
73
- desc "Verify llms-full.txt is up to date with README and docs/"
23
+ desc "Verify llms.txt and llms-full.txt are up to date, and that every doc is indexed"
74
24
  task :check do
75
- expected = build_llms_full
76
- actual = File.exist?(LLMS_FULL_PATH) ? File.read(LLMS_FULL_PATH, encoding: "UTF-8") : ""
25
+ missing = Llms.unindexed_docs
26
+ unless missing.empty?
27
+ warn "✗ These docs are not listed in Llms::DOCS, so they are missing from llms.txt and llms-full.txt:"
28
+ missing.each { |path| warn " #{path}" }
29
+ warn " Add them to tasks/llms.rb and run `bundle exec rake llms:build`."
30
+ exit 1
31
+ end
32
+
33
+ stale = Llms.outputs.reject do |path, expected|
34
+ File.exist?(path) && File.read(path, encoding: "UTF-8") == expected
35
+ end
77
36
 
78
- if expected == actual
79
- puts "✓ #{LLMS_FULL_PATH} is up to date"
37
+ if stale.empty?
38
+ puts "✓ llms.txt and llms-full.txt are up to date"
80
39
  else
81
- warn "✗ #{LLMS_FULL_PATH} is out of date with README/docs."
40
+ warn "✗ Out of date with README/docs: #{stale.keys.join(", ")}"
82
41
  warn " Run `bundle exec rake llms:build` and commit the result."
83
42
  exit 1
84
43
  end
@@ -86,7 +45,7 @@ namespace :llms do
86
45
  end
87
46
 
88
47
  # Top-level convenience alias.
89
- desc "Regenerate llms-full.txt (alias for llms:build)"
48
+ desc "Regenerate the LLM docs bundles (alias for llms:build)"
90
49
  task llms: "llms:build"
91
50
 
92
51
  task default: %i[spec rubocop llms:check]
data/docs/client.md CHANGED
@@ -59,23 +59,125 @@ contact.raw
59
59
  # => {"ContactID" => "…", "ContactPersons" => [...], "SomeNewField" => "…"}
60
60
  ```
61
61
 
62
- Use it to reach fields kiwi doesn't model yet, or to store what Xero sent
63
- verbatim.
64
-
65
- Three things to know:
66
-
62
+ Use it to reach fields kiwi doesn't model yet.
63
+
64
+ Five things to know:
65
+
66
+ - **It's the item, not the envelope.** `contact.raw` has no `"Contacts"`
67
+ key and `organisation.raw` has no `"Organisations"` key — kiwi unwraps
68
+ the envelope before building a resource, so there's nothing left of it by
69
+ the time `raw` is populated. If you need the envelope back, you're
70
+ rebuilding it yourself.
71
+ - **Nested objects return `nil`, even with `retain_raw: true`.** Line
72
+ items, addresses, contact persons and payment terms do not carry their
73
+ own payload. Reach them through the enclosing resource's hash:
74
+
75
+ ```ruby
76
+ org.addresses.map(&:raw) # => [nil] ← not what you want
77
+ org.raw["Addresses"] # => [{"AddressType" => "POBOX", …}]
78
+ ```
79
+
80
+ This is deliberate — see [why nested objects don't carry
81
+ raw](#why-nested-objects-dont-carry-raw) — but it fails quietly, so it's
82
+ worth knowing before you map over a collection and store the result.
83
+ - **It's the JSON representation.** Kiwi sends
84
+ `Accept: application/json`. Xero also serves XML, which nests
85
+ differently — XML has no arrays, so a single child parses to a Hash and
86
+ several to an Array, where JSON is always an Array. `raw` cannot
87
+ reproduce an XML-derived shape. See [migrating from an XML
88
+ client](#migrating-from-an-xml-based-client) below.
67
89
  - **`#raw` is not `#to_h`.** `to_h` is a snake_case projection rebuilt from
68
90
  the modelled attributes — different keys, different nesting. If you store
69
91
  `to_h` where you meant to store the payload, readers fail by returning
70
92
  nil rather than raising.
71
- - **It's top-level only.** Nested objects (line items, addresses, contact
72
- persons) have no `#raw` of their own. They don't need one: the top-level
73
- hash already holds every nested payload, so `contact.raw["ContactPersons"]`
74
- gets there.
75
93
  - **It costs memory.** Every resource holds its source hash alongside the
76
94
  hydrated attributes, which roughly doubles the footprint of a large page.
77
95
  That's why it's off by default.
78
96
 
97
+ ### What `raw` guarantees
98
+
99
+ Depend on these; they won't change without a major version.
100
+
101
+ - It is the parsed JSON for that resource, with **only the top-level keys
102
+ stringified**. Everything nested is exactly what `JSON.parse` produced.
103
+ - **Kiwi does not normalise it.** No key renaming, no defaulting, no
104
+ filling in of absent keys. If Xero omits `Addresses`, `raw` has no
105
+ `"Addresses"` key — it does not become `[]`. If Xero sends `[]`, you get
106
+ `[]`. `raw.fetch("Addresses", [])` and `raw["Addresses"] || []` are both
107
+ safe and mean the same thing here.
108
+ - Keys the gem doesn't model are preserved. That's the point of it.
109
+ - The top-level hash is **frozen; nested structures are not**. `raw` itself
110
+ can't be mutated, but `raw["Addresses"]` is an ordinary mutable Array.
111
+ Dup before mutating if that matters to you.
112
+
113
+ The one thing deliberately *not* guaranteed is the presence of `raw` on
114
+ nested objects, per the bullet above.
115
+
116
+ ### Why nested objects don't carry raw
117
+
118
+ Two reasons, both worth knowing if you're tempted to ask for it.
119
+
120
+ It would cost real memory on the workload `retain_raw` exists for. A page
121
+ of 1,000 invoices with five line items each retains about 3.4 MB with
122
+ top-level raw; giving every nested object its own hash adds about 1.6 MB
123
+ on top, roughly half again, and that's on a flag that has already doubled
124
+ your footprint.
125
+
126
+ More importantly it couldn't be done consistently. Several attributes —
127
+ `payment_terms` on Contact and Organisation, `invoice_addresses` on
128
+ Invoice — hydrate through custom lambdas that construct their objects
129
+ directly, so they'd stay `nil` while their siblings worked. "Nested
130
+ objects never carry raw" is a rule you can hold in your head. "Nested
131
+ objects carry raw except these four" is not.
132
+
133
+ ### Migrating from an XML-based client
134
+
135
+ If you're replacing a Xero client that sent `Accept: text/xml` — HTTParty
136
+ and similar default to it — any payloads you already have stored are
137
+ XML-shaped, and `raw` will not match them. The differences are structural,
138
+ not cosmetic:
139
+
140
+ | | XML (`text/xml`) | JSON (`application/json`) |
141
+ |---|---|---|
142
+ | Organisation body | `{"Organisations" => {"Organisation" => {…}}}` | `{"Organisations" => [{…}]}` |
143
+ | One address | `{"Address" => {…}}` | `[{…}]` |
144
+ | Several addresses | `{"Address" => [{…}, {…}]}` | `[{…}, {…}]` |
145
+
146
+ The single-child collapse is the one that catches people: under XML a
147
+ contact with one person parses to a Hash and a contact with two parses to
148
+ an Array, from the same endpoint. Code written against that has a
149
+ normalising step somewhere, whether or not its author knew why.
150
+
151
+ No client setting reproduces these shapes — they're artefacts of an XML
152
+ parse kiwi doesn't do. But the remedy isn't the same everywhere, and it's
153
+ worth sorting your readers into two piles before planning the work.
154
+
155
+ **Readers that dig the XML-only structure have to change.** Something like
156
+ `dig("Organisations", "Organisation", "Addresses", "Address")` returns nil
157
+ against anything kiwi produces, whether you store `raw` or `to_h`.
158
+ Promote the fields those readers need to real columns and use `raw` for
159
+ the backfill — it's the true payload, so it carries everything required to
160
+ populate them.
161
+
162
+ **Readers that already normalise usually survive untouched.** A reader
163
+ doing `[value].flatten.compact` handles the Hash case, the Array case and
164
+ nil identically, so a JSON array flows straight through. If the keys it
165
+ reads are the same in both representations, only the *writer* changes:
166
+ stop unwrapping, store the array. Existing rows stay readable, and you
167
+ skip a migration entirely.
168
+
169
+ Type coercion is usually fine in that second pile too. XML gives you
170
+ strings — `"false"` rather than `false` — and if the reader hands that to
171
+ something like ActiveModel's boolean cast, both the old string and the new
172
+ real boolean land on the same value.
173
+
174
+ One piece of luck worth knowing about: the two piles tend to fail
175
+ differently. A `dig` that misses returns nil and writes a blank record
176
+ quietly. Code that assumed a Hash, such as `Array#to_h` on what is now a
177
+ list, raises `TypeError` on the first record with data in it. The noisy
178
+ failures are the ones you can trust to find themselves — budget your
179
+ review time for the silent ones.
180
+
79
181
  ## What the client gives you
80
182
 
81
183
  | Method | Returns | Purpose |
@@ -135,9 +135,32 @@ module XeroKiwi
135
135
  end
136
136
  end
137
137
 
138
- # Xero's response hash for this resource, exactly as it arrived, or nil
139
- # unless the client was built with `retain_raw: true`. Use it to reach
140
- # fields the gem doesn't model, or to store the payload verbatim.
138
+ # This resource's own hash from Xero's JSON response, exactly as it
139
+ # arrived, or nil unless the client was built with `retain_raw: true`.
140
+ # Use it to reach fields the gem doesn't model.
141
+ #
142
+ # Returns nil on NESTED objects even when retain_raw is on — line
143
+ # items, addresses, contact persons, payment terms. Reach those
144
+ # through the enclosing resource instead:
145
+ #
146
+ # org.addresses.map(&:raw) # => [nil]
147
+ # org.raw["Addresses"] # => [{"AddressType" => "POBOX", ...}]
148
+ #
149
+ # Scope is the item, NOT the enclosing envelope: `contact.raw` has no
150
+ # "Contacts" key, and `organisation.raw` no "Organisations" key. Kiwi
151
+ # unwraps the envelope before a resource is built, so there is nothing
152
+ # left of it by the time this is populated.
153
+ #
154
+ # Kiwi never normalises what it stores here: absent keys stay absent
155
+ # rather than becoming [] or nil entries, and keys the gem doesn't
156
+ # model are preserved. The hash itself is frozen; the structures
157
+ # nested inside it are not.
158
+ #
159
+ # It is also the JSON representation specifically. Kiwi sends
160
+ # `Accept: application/json`; Xero's XML representation nests
161
+ # differently — no arrays, so one child is a Hash and several are an
162
+ # Array — and `raw` cannot reproduce that shape. Worth knowing when
163
+ # migrating off an XML-based Xero client with stored payloads.
141
164
  #
142
165
  # Note `to_h` is NOT this — it's a snake_case projection rebuilt from
143
166
  # the modelled attributes, with different keys and different nesting.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module XeroKiwi
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.2"
5
5
  end