From 64e2f441ba5d48def2de51ec67c609b1b745f89b Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Wed, 16 Sep 2026 14:52:38 +0000 Subject: [PATCH 1/2] Bump GOBL to v0.507.0 and refresh addon modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the core GOBL requirement to v0.507.0 and move every addon module to its latest release: gobl.br.nfse v0.0.1 -> v0.0.2 gobl.dk.oioubl v0.0.1 -> v0.0.6 gobl.mx.cfdi v0.63.0 -> v0.64.0 gobl.pt.saft v0.0.6 -> v0.0.7 Two upstream changes needed handling: GOBL 0.507.0 adds pl-favat-v3 to its approved addon list, so the bundle registration test failed. Add gobl.pl.ksef and its blank import. Only v0.45.0 is built against 0.507.0; v0.44.0 and earlier target gobl 0.402.0. GOBL 0.507.0 also deregisters the org/unit schema — Unit("") is dropped from schema.Register and unitRules() from the rules registry in org/org.go. Remove the now-stale entry from the expected schema list. Verified by building all 166 example and test documents shipped by the nine addon modules against the upgraded stack. Several addons still target older GOBL lines (sa.zatca on 0.500.0, br.nfe/br.nfse on 0.502.2) but execute correctly, so the lag is benign. Co-Authored-By: Claude Opus 5 (1M context) --- bundle/bundle.go | 1 + go.mod | 13 +++++++------ go.sum | 26 ++++++++++++++------------ internal/ops/bulk_test.go | 2 +- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/bundle/bundle.go b/bundle/bundle.go index 41e4e2e..6156599 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -12,6 +12,7 @@ import ( _ "github.com/invopop/gobl.fr.ctc/addon" _ "github.com/invopop/gobl.it.sdi/addon" _ "github.com/invopop/gobl.mx.cfdi/addon" + _ "github.com/invopop/gobl.pl.ksef/addon" _ "github.com/invopop/gobl.pt.saft/addon" _ "github.com/invopop/gobl.sa.zatca/addon" ) diff --git a/go.mod b/go.mod index 63c11a0..3621427 100644 --- a/go.mod +++ b/go.mod @@ -6,15 +6,16 @@ require ( github.com/a-h/templ v0.3.1001 github.com/google/go-cmp v0.7.0 github.com/imdario/mergo v0.3.16 - github.com/invopop/gobl v0.505.0 + github.com/invopop/gobl v0.507.0 github.com/invopop/gobl.br.nfe v0.0.4 - github.com/invopop/gobl.br.nfse v0.0.1 - github.com/invopop/gobl.dk.oioubl v0.0.1 + github.com/invopop/gobl.br.nfse v0.0.2 + github.com/invopop/gobl.dk.oioubl v0.0.6 github.com/invopop/gobl.fi.finvoice v0.0.2 github.com/invopop/gobl.fr.ctc v0.0.8 github.com/invopop/gobl.it.sdi v0.77.0 - github.com/invopop/gobl.mx.cfdi v0.63.0 - github.com/invopop/gobl.pt.saft v0.0.6 + github.com/invopop/gobl.mx.cfdi v0.64.0 + github.com/invopop/gobl.pl.ksef v0.45.0 + github.com/invopop/gobl.pt.saft v0.0.7 github.com/invopop/gobl.sa.zatca v0.0.4 github.com/invopop/icons v0.14.0 github.com/invopop/popui.go v0.30.0 @@ -28,7 +29,7 @@ require ( ) require ( - cloud.google.com/go v0.119.0 // indirect + cloud.google.com/go v0.121.3 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/Oudwins/tailwind-merge-go v0.2.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect diff --git a/go.sum b/go.sum index c184275..064eb97 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cloud.google.com/go v0.119.0 h1:tw7OjErMzJKbbjaEHkrt60KQrK5Wus/boCZ7tm5/RNE= -cloud.google.com/go v0.119.0/go.mod h1:fwB8QLzTcNevxqi8dcpR+hoMIs3jBherGS9VUBDAW08= +cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo= +cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc= github.com/LastPossum/kamino v0.0.2 h1:Zry5lS7x7TTU1hzzk3Utnp+rX8kk/wWhuW52Ha9As+U= github.com/LastPossum/kamino v0.0.2/go.mod h1:H8Qm+6DGeNOoXk9hHIOEAQWS9nbo0YwK32pC/7REsOE= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= @@ -33,24 +33,26 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/gobl v0.505.0 h1:9ulIjXnoHt9bgJaOAVAoH08J9B9ETHIKPG097zbYlkc= -github.com/invopop/gobl v0.505.0/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= +github.com/invopop/gobl v0.507.0 h1:vg4Ohs8LAaDC/1YN9n4xOZrmQ83T7k98aUlw8HlG4nc= +github.com/invopop/gobl v0.507.0/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= github.com/invopop/gobl.br.nfe v0.0.4 h1:/RWQEwP8pHej+8u6u/BNeLKjYZ4NyZJRjWGF2cPEZdo= github.com/invopop/gobl.br.nfe v0.0.4/go.mod h1:qXzt4bz9S4+nJRbREb9QEAoO1W9r3Sjy6R207W7IuhY= -github.com/invopop/gobl.br.nfse v0.0.1 h1:BdwNiG7vk7bPMgE4m102P+UysOxmYWhOKIDfFI6RKEA= -github.com/invopop/gobl.br.nfse v0.0.1/go.mod h1:m22voo72ZScRiwIS49wXhzGRahzBO+IEAN0M0Kij3QI= -github.com/invopop/gobl.dk.oioubl v0.0.1 h1:A6owymml+RowMQdWnx2MV6usuMgmdOTl8n34f3MFFaQ= -github.com/invopop/gobl.dk.oioubl v0.0.1/go.mod h1:cVgy+dF9eaKd/qkfrRxr9pq2IyQWAWORk3Ol5rW9m0s= +github.com/invopop/gobl.br.nfse v0.0.2 h1:lhHnDBxs8sYBjWQ+nZ8q5/+6OUwRGPedgjJuxHDQdmI= +github.com/invopop/gobl.br.nfse v0.0.2/go.mod h1:rgq6Kw/yaRexA7yf9+sbAI4Ugj+DrlIKsWLxv8K2lAI= +github.com/invopop/gobl.dk.oioubl v0.0.6 h1:45Qg561DcB34Ldc8TDjvlP+3t7L43PqKxNXxphaWzqs= +github.com/invopop/gobl.dk.oioubl v0.0.6/go.mod h1:FFKB5iY3LtJZSsc+GqIs2vaptzInrVQjPEv9m2Ld2vQ= github.com/invopop/gobl.fi.finvoice v0.0.2 h1:yOPLBUVNZ8Uk3KEaYXjXTzqy4HV3xdyT4vkrp4ydtYM= github.com/invopop/gobl.fi.finvoice v0.0.2/go.mod h1:3NenHpVprZgry3em/42ddMBxTfngCCjpiKePAxoejbE= github.com/invopop/gobl.fr.ctc v0.0.8 h1:T4/QTD1qfHdACEeZSw6jMkK8U0eiwpaJXv1QZs6n6dc= github.com/invopop/gobl.fr.ctc v0.0.8/go.mod h1:S7D4Oz8cvKJ5usaY9Esjf6efx4gfDaC51/s0y9UVUF4= github.com/invopop/gobl.it.sdi v0.77.0 h1:v9IgNJrIDkb5aPZZJPkXcHmS3sMojCC7aafTCjn47V4= github.com/invopop/gobl.it.sdi v0.77.0/go.mod h1:+SfnlFTPjKSZZBrKEzVlrFePukufGV2AMSA5RFoWGEw= -github.com/invopop/gobl.mx.cfdi v0.63.0 h1:WkwsFCfMZfrG3N8ZHV+7NZ9VlCkYh4vRSUdqvrtEyHA= -github.com/invopop/gobl.mx.cfdi v0.63.0/go.mod h1:sXy7JIXauSKRqT0HgHsFICIV9YXuSHK3Quq9uZWZ9L0= -github.com/invopop/gobl.pt.saft v0.0.6 h1:K0pcFMq/Q5ojPJqdmRsYJKkzWcKWeyh9pPC5BkPUE14= -github.com/invopop/gobl.pt.saft v0.0.6/go.mod h1:z5L+dLFBx6PmEdacnFWehYWgiYZSM2HaPzx6ld+DnQs= +github.com/invopop/gobl.mx.cfdi v0.64.0 h1:JThygwAHB9TyR9seSrJepmfE77i51DLYDUKLOaJBZyU= +github.com/invopop/gobl.mx.cfdi v0.64.0/go.mod h1:Y0T5b3cruP4kx0IV/KHEhNVpm8O0FYbSCHXaBMWXKzQ= +github.com/invopop/gobl.pl.ksef v0.45.0 h1:XlK33Geb9aDG0G5PyCGa0Rp2JCcLDWq7JuiJEpMVEpA= +github.com/invopop/gobl.pl.ksef v0.45.0/go.mod h1:aAbp+2Z7kBosREe/kMzfW2Fm6Dud7E0LwvK7tFY1QF4= +github.com/invopop/gobl.pt.saft v0.0.7 h1:QCk+BA3nrWT6ikFLot2dmC/59Lj5U1BjPbHOCPy4RsU= +github.com/invopop/gobl.pt.saft v0.0.7/go.mod h1:jFAvKmLpSw566qvafNFbkvHdZz1ZjSjNgQvgcX5Gdxk= github.com/invopop/gobl.sa.zatca v0.0.4 h1:0BQKJdPazvdXCRosSU5pkQKCaexGQBATXEVqbAMajYk= github.com/invopop/gobl.sa.zatca v0.0.4/go.mod h1:tmLCoaKb4X7sZ2zRU8LalA1sgxM2kQv/uqMzZslhE6A= github.com/invopop/icons v0.14.0 h1:Bk0hF+EI/x3XwWX1IqMeFDMdu766zY3zFBqTkImCdhU= diff --git a/internal/ops/bulk_test.go b/internal/ops/bulk_test.go index 50ec28f..10f918b 100644 --- a/internal/ops/bulk_test.go +++ b/internal/ops/bulk_test.go @@ -618,7 +618,7 @@ func TestBulk(t *testing.T) { //nolint:gocyclo // Following raw message is copied and pasted! (sorry!) Payload: json.RawMessage(`{ "list": [ - "https://gobl.org/draft-0/bill/action", "https://gobl.org/draft-0/bill/charge", "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/delivery", "https://gobl.org/draft-0/bill/delivery-details", "https://gobl.org/draft-0/bill/discount", "https://gobl.org/draft-0/bill/fault", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/bill/line", "https://gobl.org/draft-0/bill/order", "https://gobl.org/draft-0/bill/ordering", "https://gobl.org/draft-0/bill/payment", "https://gobl.org/draft-0/bill/payment-details", "https://gobl.org/draft-0/bill/reason", "https://gobl.org/draft-0/bill/status", "https://gobl.org/draft-0/bill/status-line", "https://gobl.org/draft-0/bill/tax", "https://gobl.org/draft-0/bill/totals", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cal/time", "https://gobl.org/draft-0/cal/timestamp", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/definition", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/source", "https://gobl.org/draft-0/cbc/uri", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/attachment", "https://gobl.org/draft-0/org/attribute", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/endpoint", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/note", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/unit", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/card", "https://gobl.org/draft-0/pay/credit-transfer", "https://gobl.org/draft-0/pay/direct-debit", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/online", "https://gobl.org/draft-0/pay/record", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/addon-list", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/correction-definition", "https://gobl.org/draft-0/tax/correction-set", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/note", "https://gobl.org/draft-0/tax/regime-code", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/scenario", "https://gobl.org/draft-0/tax/scenario-set", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/tag-set", "https://gobl.org/draft-0/tax/total" + "https://gobl.org/draft-0/bill/action", "https://gobl.org/draft-0/bill/charge", "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/delivery", "https://gobl.org/draft-0/bill/delivery-details", "https://gobl.org/draft-0/bill/discount", "https://gobl.org/draft-0/bill/fault", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/bill/line", "https://gobl.org/draft-0/bill/order", "https://gobl.org/draft-0/bill/ordering", "https://gobl.org/draft-0/bill/payment", "https://gobl.org/draft-0/bill/payment-details", "https://gobl.org/draft-0/bill/reason", "https://gobl.org/draft-0/bill/status", "https://gobl.org/draft-0/bill/status-line", "https://gobl.org/draft-0/bill/tax", "https://gobl.org/draft-0/bill/totals", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cal/time", "https://gobl.org/draft-0/cal/timestamp", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/definition", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/source", "https://gobl.org/draft-0/cbc/uri", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/attachment", "https://gobl.org/draft-0/org/attribute", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/endpoint", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/note", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/card", "https://gobl.org/draft-0/pay/credit-transfer", "https://gobl.org/draft-0/pay/direct-debit", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/online", "https://gobl.org/draft-0/pay/record", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/addon-list", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/correction-definition", "https://gobl.org/draft-0/tax/correction-set", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/note", "https://gobl.org/draft-0/tax/regime-code", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/scenario", "https://gobl.org/draft-0/tax/scenario-set", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/tag-set", "https://gobl.org/draft-0/tax/total" ] }`), IsFinal: false, From e2e16c74464ce7b4d0b20b6931193de9e6323087 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Wed, 16 Sep 2026 15:01:31 +0000 Subject: [PATCH 2/2] Serve addon definitions from the registry, not embedded data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v0/addons/{key} returned 404 for every addon implemented in its own module — mx-cfdi-v4, it-sdi-v1, pt-saft-v1, sa-zatca-v1, br-nfe-v4, br-nfse-v1, dk-oioubl-v2, fi-finvoice-v3 and the three fr-ctc flows. The handler read addons/.json from core GOBL's embedded data directory. Addons that moved out to their own modules register themselves via init() when the bundle blank-imports them, but ship no file in that directory, so only the addons still compiled into core resolved. The list endpoint meanwhile reads the in-memory registry, so /v0/addons advertised 25 addons of which 12 could not be fetched. Render the definition from the registry instead, via a new ops.AddonData helper shared by the API and MCP server. This mirrors what core GOBL's addons/generate.go does to produce those files, so output for the addons still in core is byte-for-byte identical. The same read was duplicated in the MCP addon tool and the gobl://addons/{key} resource, and the gobl://addons resource listed the embedded directory rather than the registry; all now go through the helper. Tests assert the invariant that broke — everything the registry reports must be retrievable — so an addon moving out of core cannot regress this again. They cover the API endpoint, both MCP paths, and the helper, and the test packages now blank-import the bundle as the real binaries do; without it no externally-implemented addon is registered and the gap is invisible, which is how this shipped. One deliberate behaviour change: /v0/addons/pl-favat-v2 no longer resolves. It was a stale file left in core GOBL 0.505's data directory for an addon that is not registered, so it was never listed by /v0/addons and could not be used in a document's $addons. It is absent from core GOBL 0.507 entirely. Co-Authored-By: Claude Opus 5 (1M context) --- api/addons.go | 18 ++++--- api/api_test.go | 44 +++++++++++++++++ internal/mcp/resources.go | 15 ++---- internal/mcp/resources_test.go | 33 +++++++++++++ internal/mcp/tools.go | 9 +--- internal/mcp/tools_test.go | 20 ++++++++ internal/ops/addons.go | 53 +++++++++++++++++++++ internal/ops/addons_test.go | 87 ++++++++++++++++++++++++++++++++++ 8 files changed, 250 insertions(+), 29 deletions(-) create mode 100644 internal/ops/addons.go create mode 100644 internal/ops/addons_test.go diff --git a/api/addons.go b/api/addons.go index 8cadf98..d46ad14 100644 --- a/api/addons.go +++ b/api/addons.go @@ -1,13 +1,12 @@ package api import ( + "errors" "net/http" - "path" - "strings" "github.com/invopop/gobl" + "github.com/invopop/gobl.dev/internal/ops" "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/data" "github.com/invopop/gobl/i18n" "github.com/invopop/gobl/tax" ) @@ -40,14 +39,13 @@ func handleAddon(w http.ResponseWriter, r *http.Request) { return } - if !strings.HasSuffix(key, ".json") { - key = key + ".json" - } - p := path.Join("addons", key) - - d, err := data.Content.ReadFile(p) + d, err := ops.AddonData(key) if err != nil { - WriteError(w, gobl.ErrNotFound.WithReason("addon not found")) + if errors.Is(err, ops.ErrAddonNotFound) { + WriteError(w, gobl.ErrNotFound.WithReason("addon not found")) + return + } + WriteError(w, gobl.ErrInternal.WithCause(err)) return } WriteRawJSON(w, d) diff --git a/api/api_test.go b/api/api_test.go index d4db18c..7601c11 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -10,7 +10,14 @@ import ( "github.com/invopop/gobl" "github.com/invopop/gobl.dev/api" + + // Register the full GOBL addon set, as the real binaries do, so the + // addon endpoints are exercised against externally-implemented addons + // and not just those compiled into core GOBL. + _ "github.com/invopop/gobl.dev/bundle" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -556,6 +563,43 @@ func TestAddonEndpoint(t *testing.T) { defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusNotFound, resp.StatusCode) }) + + // Addons implemented in their own modules ship no file in core GOBL's + // embedded data directory, so serving them from there returned 404 even + // though /addons advertised them. + t.Run("externally implemented addon", func(t *testing.T) { + resp, err := http.Get(srv.URL + prefix + "/addons/mx-cfdi-v4") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + var def map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&def)) + assert.Equal(t, "mx-cfdi-v4", def["key"]) + assert.Equal(t, "https://gobl.org/draft-0/tax/addon-def", def["$schema"]) + assert.NotEmpty(t, def["extensions"], "expected the full definition, not just a stub") + }) + + // The invariant that actually broke: anything /addons lists must be + // retrievable from /addons/{key}, whichever module implements it. + t.Run("every listed addon is retrievable", func(t *testing.T) { + defs := tax.AllAddonDefs() + require.NotEmpty(t, defs) + + for _, def := range defs { + key := def.Key.String() + t.Run(key, func(t *testing.T) { + resp, err := http.Get(srv.URL + prefix + "/addons/" + key) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + var got map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + assert.Equal(t, key, got["key"]) + }) + } + }) } func TestRegimeListEndpoint(t *testing.T) { diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 5aab642..c74d334 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -7,6 +7,7 @@ import ( "path" "strings" + "github.com/invopop/gobl.dev/internal/ops" "github.com/invopop/gobl/data" "github.com/invopop/gobl/schema" "github.com/mark3labs/mcp-go/mcp" @@ -129,12 +130,7 @@ func handleAddonResource(_ context.Context, request mcp.ReadResourceRequest) ([] return nil, fmt.Errorf("missing addon key") } - if !strings.HasSuffix(key, ".json") { - key = key + ".json" - } - p := path.Join("addons", key) - - d, err := data.Content.ReadFile(p) + d, err := ops.AddonData(key) if err != nil { return nil, fmt.Errorf("addon not found: %w", err) } @@ -190,12 +186,7 @@ func handleRegimeList(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.Resou } func handleAddonList(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - items, err := listDataDir("addons") - if err != nil { - return nil, err - } - - d, err := marshalJSON(map[string]any{"addons": items}) + d, err := marshalJSON(map[string]any{"addons": ops.AddonKeys()}) if err != nil { return nil, err } diff --git a/internal/mcp/resources_test.go b/internal/mcp/resources_test.go index 77e34e6..278e7d0 100644 --- a/internal/mcp/resources_test.go +++ b/internal/mcp/resources_test.go @@ -5,6 +5,12 @@ import ( "encoding/json" "testing" + // Register the full GOBL addon set, as the real binaries do, so the addon + // resources are exercised against externally-implemented addons and not + // just those compiled into core GOBL. + _ "github.com/invopop/gobl.dev/bundle" + + "github.com/invopop/gobl/tax" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -138,6 +144,30 @@ func TestHandleAddonResource(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "addon not found") }) + + // Addons implemented in their own modules ship no file in core GOBL's + // embedded data directory, so reading from there returned "addon not + // found" for every one of them. + t.Run("every registered addon is readable", func(t *testing.T) { + defs := tax.AllAddonDefs() + require.NotEmpty(t, defs) + + for _, def := range defs { + key := def.Key.String() + t.Run(key, func(t *testing.T) { + result, err := handleAddonResource(context.Background(), makeReadRequest("gobl://addons/"+key)) + require.NoError(t, err) + require.Len(t, result, 1) + + tc, ok := result[0].(mcp.TextResourceContents) + require.True(t, ok) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(tc.Text), &got)) + assert.Equal(t, key, got["key"]) + }) + } + }) } func TestHandleSchemaList(t *testing.T) { @@ -192,6 +222,9 @@ func TestHandleAddonList(t *testing.T) { } require.NoError(t, json.Unmarshal([]byte(tc.Text), &data)) assert.NotEmpty(t, data.Addons) + assert.Contains(t, data.Addons, "es-verifactu-v1", "core addon missing") + assert.Contains(t, data.Addons, "mx-cfdi-v4", "externally-implemented addon missing") + assert.Len(t, data.Addons, len(tax.AllAddonDefs())) } func TestListDataDir(t *testing.T) { diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 5e913dc..ad4f4ee 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -10,10 +10,10 @@ import ( "strings" "github.com/invopop/gobl" + "github.com/invopop/gobl.dev/internal/ops" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/data" "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl.dev/internal/ops" "github.com/invopop/gobl/pkg/here" "github.com/invopop/gobl/tax" "github.com/mark3labs/mcp-go/mcp" @@ -302,12 +302,7 @@ func handleAddonTool(_ context.Context, request mcp.CallToolRequest) (*mcp.CallT return mcp.NewToolResultError("'key' argument is required"), nil } - if !strings.HasSuffix(key, ".json") { - key = key + ".json" - } - p := path.Join("addons", key) - - d, err := data.Content.ReadFile(p) + d, err := ops.AddonData(key) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("addon not found: %s", err)), nil } diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go index 30425af..4bc61e4 100644 --- a/internal/mcp/tools_test.go +++ b/internal/mcp/tools_test.go @@ -7,6 +7,12 @@ import ( "github.com/invopop/gobl" goblmcp "github.com/invopop/gobl.dev/internal/mcp" + + // Register the full GOBL addon set, as the real binaries do, so the addon + // tools are exercised against externally-implemented addons and not just + // those compiled into core GOBL. + _ "github.com/invopop/gobl.dev/bundle" + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/assert" @@ -481,6 +487,20 @@ func TestAddonTool(t *testing.T) { text := extractText(t, result) assert.Contains(t, text, "key") }) + + // Addons implemented in their own modules ship no file in core GOBL's + // embedded data directory, so the tool reported them as not found. + t.Run("externally implemented addon", func(t *testing.T) { + result := callTool(t, s, "addon", map[string]any{ + "key": "mx-cfdi-v4", + }) + require.False(t, result.IsError) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(extractText(t, result)), &got)) + assert.Equal(t, "mx-cfdi-v4", got["key"]) + assert.NotEmpty(t, got["extensions"], "expected the full definition, not just a stub") + }) } func TestAddonListTool(t *testing.T) { diff --git a/internal/ops/addons.go b/internal/ops/addons.go new file mode 100644 index 0000000..9c14924 --- /dev/null +++ b/internal/ops/addons.go @@ -0,0 +1,53 @@ +package ops + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/schema" + "github.com/invopop/gobl/tax" +) + +// ErrAddonNotFound is returned by [AddonData] when no addon is registered +// under the requested key. +var ErrAddonNotFound = errors.New("addon not found") + +// AddonData renders the full definition of the addon registered under key as +// indented JSON, wrapped in a schema object. +// +// The definition comes from the in-memory registry rather than GOBL's embedded +// data directory. Addons implemented in their own modules (gobl.mx.cfdi, +// gobl.it.sdi, and so on) register themselves via init() when blank-imported by +// the bundle, but ship no file inside core GOBL's data/addons — reading from +// there would serve only the addons still compiled into core. The output +// matches core GOBL's generated files byte for byte, since this mirrors what +// its addons/generate.go does. +// +// A ".json" suffix on key is accepted and ignored. +func AddonData(key string) ([]byte, error) { + key = strings.TrimSuffix(key, ".json") + + def := tax.AddonForKey(cbc.Key(key)) + if def == nil { + return nil, ErrAddonNotFound + } + + obj, err := schema.NewObject(def) + if err != nil { + return nil, err + } + return json.MarshalIndent(obj, "", " ") +} + +// AddonKeys returns the key of every registered addon, ordered as the registry +// reports them. +func AddonKeys() []string { + defs := tax.AllAddonDefs() + keys := make([]string, len(defs)) + for i, def := range defs { + keys[i] = string(def.Key) + } + return keys +} diff --git a/internal/ops/addons_test.go b/internal/ops/addons_test.go new file mode 100644 index 0000000..0348430 --- /dev/null +++ b/internal/ops/addons_test.go @@ -0,0 +1,87 @@ +package ops_test + +import ( + "encoding/json" + "testing" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl.dev/internal/ops" + + // Register the full GOBL addon set so externally-implemented addons are + // covered, not just those compiled into core GOBL. + _ "github.com/invopop/gobl.dev/bundle" + + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddonData(t *testing.T) { + t.Run("core addon", func(t *testing.T) { + d, err := ops.AddonData("es-verifactu-v1") + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(d, &got)) + assert.Equal(t, "es-verifactu-v1", got["key"]) + assert.Equal(t, "https://gobl.org/draft-0/tax/addon-def", got["$schema"]) + }) + + // Addons living in their own modules ship no file in core GOBL's embedded + // data directory, so they have to be rendered from the registry. + t.Run("externally implemented addon", func(t *testing.T) { + d, err := ops.AddonData("mx-cfdi-v4") + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(d, &got)) + assert.Equal(t, "mx-cfdi-v4", got["key"]) + assert.NotEmpty(t, got["extensions"], "expected the full definition, not just a stub") + }) + + t.Run("json suffix accepted", func(t *testing.T) { + with, err := ops.AddonData("mx-cfdi-v4.json") + require.NoError(t, err) + without, err := ops.AddonData("mx-cfdi-v4") + require.NoError(t, err) + assert.Equal(t, without, with) + }) + + t.Run("unknown key", func(t *testing.T) { + _, err := ops.AddonData("nonexistent-addon") + assert.ErrorIs(t, err, ops.ErrAddonNotFound) + }) + + t.Run("empty key", func(t *testing.T) { + _, err := ops.AddonData("") + assert.ErrorIs(t, err, ops.ErrAddonNotFound) + }) + + // Guards the invariant that broke: every registered addon must render, + // whichever module implements it. + t.Run("every registered addon renders", func(t *testing.T) { + defs := tax.AllAddonDefs() + require.NotEmpty(t, defs) + + for _, def := range defs { + key := def.Key.String() + t.Run(key, func(t *testing.T) { + d, err := ops.AddonData(key) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(d, &got)) + assert.Equal(t, key, got["key"]) + }) + } + }) +} + +func TestAddonKeys(t *testing.T) { + keys := ops.AddonKeys() + require.NotEmpty(t, keys) + + assert.Len(t, keys, len(tax.AllAddonDefs())) + assert.Contains(t, keys, "es-verifactu-v1", "core addon missing") + assert.Contains(t, keys, "mx-cfdi-v4", "externally-implemented addon missing") +}