Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions api/addons.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
13 changes: 7 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
26 changes: 14 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
Expand Down
15 changes: 3 additions & 12 deletions internal/mcp/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down
33 changes: 33 additions & 0 deletions internal/mcp/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 2 additions & 7 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
20 changes: 20 additions & 0 deletions internal/mcp/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
53 changes: 53 additions & 0 deletions internal/ops/addons.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading