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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.25-alpine AS builder
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
Expand Down
57 changes: 55 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Released under the Apache 2.0 [LICENSE](https://github.com/invopop/gobl.dev/blob
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/invopop/gobl.dev)

Core GOBL is a pure document library. This project composes it with every
GOBL addon (see [`bundle/`](./bundle)) and ships two binaries:
GOBL addon and converter (see [`bundle/`](./bundle)) and ships two binaries:

| Binary | Package | What it is |
|------------|------------------|------------|
Expand Down Expand Up @@ -43,6 +43,7 @@ Commands:
| `gobl sign` | Sign an envelope with a JWK private key. |
| `gobl verify` | Verify an envelope's signatures. |
| `gobl replicate` | Clone a document with a fresh UUID. |
| `gobl convert` | Convert documents between GOBL and external formats such as UBL and CII (see [Converters](#converters)). |
| `gobl keygen` | Generate an ES256 key pair. |
| `gobl serve` | Launch the HTTP API server (see [API](#http-api)). |
| `gobl mcp` | Launch a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio for AI tools and editors. |
Expand All @@ -60,6 +61,13 @@ gobl build -i ./invoice.yaml --set-file customer=./party.yaml --set series=TEST
# Correct an invoice with a credit note
gobl correct -i ./invoice.json --credit

# Export an invoice as UBL, and import it back into GOBL
gobl convert ./invoice.json ./invoice.xml --to ubl+en16931-v2017
gobl convert -i ./invoice.xml

# List the available conversion formats
gobl convert --list -i

# Serve the HTTP API on :8080
gobl serve
```
Expand All @@ -77,6 +85,23 @@ PORT=3000 go run ./cmd/gobl.dev
Open [http://localhost:8080](http://localhost:8080) for the editor; the API is
served under `/v0` (see below).

The editor has two modes:

- **Build** calculates and validates the GOBL document, replacing it with the
result.
- **Convert** shows the GOBL document beside a document in another format,
both editable. **Export →**, above the GOBL editor, builds the GOBL document
without changing it and writes it into the selected format; only formats
that accept the document are offered, as checked through `POST /v0/formats`
while it is edited. **← Import**, above the other editor, detects that
document's format and replaces the GOBL document with the imported one.

Bare-minimum example documents for each region live in
[`editor/examples`](./editor/examples), picked above the GOBL editor, and
offered above the other editor as samples exported into their most specific
format. They are labelled from their regime and addons, so adding one only
requires adding its file.

### Docker

```bash
Expand All @@ -102,13 +127,17 @@ the `/v0` prefix:
| `POST` | `/v0/verify` | Verify an envelope's signatures |
| `POST` | `/v0/correct` | Generate a corrective document (credit/debit note) |
| `POST` | `/v0/replicate` | Clone a document with a new UUID |
| `POST` | `/v0/import` | Detect the format of the raw body (e.g. UBL XML) and import it into a GOBL envelope; `?format=` limits detection |
| `POST` | `/v0/export?format={key}` | Export a GOBL document or envelope into the first accepting format; the key used is returned in the `GOBL-Format` header |
| `POST` | `/v0/keygen` | Generate an ES256 key pair |
| `GET` | `/v0/schemas` | List registered JSON schemas |
| `GET` | `/v0/schemas/{path}` | Fetch a specific schema (add `?bundle` for bundled) |
| `GET` | `/v0/regimes` | List available tax regimes |
| `GET` | `/v0/regimes/{code}` | Fetch a specific tax regime definition |
| `GET` | `/v0/addons` | List available addons |
| `GET` | `/v0/addons/{key}` | Fetch a specific addon definition |
| `GET` | `/v0/formats` | List the conversion formats registered by the bundled converters |
| `POST` | `/v0/formats` | Check which formats accept a GOBL document, with the reasons for refusals |
| `POST` | `/v0/mcp` | Model Context Protocol (MCP) endpoint |
| `GET` | `/v0/openapi.json` | OpenAPI specification |

Expand All @@ -129,14 +158,38 @@ workflow attaches the wasm build to each GitHub Release, uploads it to
addons the binaries ship with — one blank import per addon module. Add an
approved addon there and both `gobl` and `gobl.dev` pick it up.

## Converters

Conversions between GOBL and external formats go through GOBL's
[`convert`](https://github.com/invopop/gobl/tree/main/convert) register:
converter modules register their formats when imported, and gobl.dev offers
exactly the formats registered, through `gobl convert`, the `/v0/import`,
`/v0/export` and `/v0/formats` endpoints, and the editor. The converters are
blank-imported in [`bundle/bundle.go`](./bundle/bundle.go) alongside the
addons:

| Module | Formats |
|--------|---------|
| [gobl.ubl](https://github.com/invopop/gobl.ubl) | `ubl+en16931-v2017`, `ubl+peppol+bis-v3` |
| [gobl.cii](https://github.com/invopop/gobl.cii) | `cii+en16931-v2017`, `cii+peppol+bis-v3` |
| [gobl.de.xinvoice](https://github.com/invopop/gobl.de.xinvoice) | `ubl+de-xrechnung-v3`, `cii+de-xrechnung-v3`, `cii+de-zugferd-v2` (`+basic`, `+extended`) |
| [gobl.fr.ctc](https://github.com/invopop/gobl.fr.ctc) | `ubl+fr-ctc-cius-v1`, `ubl+fr-ctc-extended-v1`, `cii+fr-ctc-cius-v1`, `cii+fr-ctc-extended-v1`, `cii+fr-ctc-facturx-v1`, `cii+fr-facturx-v1` (`+basic`, `+extended`), `cii+fr-choruspro-v1`, `cdar+fr-ctc-cdv-v1` (`+ppf`) |
| [gobl.sa.zatca](https://github.com/invopop/gobl.sa.zatca) | `ubl+sa-zatca-v1` |

Format keys follow GOBL's naming rules (`<syntax>[+<layer>...]`). Each
format's converter decides whether it accepts a document, typically requiring
the addons of its specification, and explains a refusal with rules faults.
`POST /v0/formats` reports, for each format that exports a document's schema,
whether it accepts the document and why not.

## Project structure

```
cmd/
gobl/ CLI entry point (binary: gobl)
gobl.dev/ web server entry point (binary: gobl.dev)
api/ HTTP API handler (package api)
bundle/ addon registration (package bundle)
bundle/ addon and converter registration (package bundle)
editor/ browser editor (PopUI + Templ)
internal/
ops/ document operations engine (build, validate, sign, …)
Expand Down
9 changes: 7 additions & 2 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ func WithRoutes(fn func(mux *http.ServeMux, prefix string)) Option {
// registered under the major-version prefix (e.g. /v0/build).
//
// By default the handler includes document operations (build, validate,
// correct, replicate, sign, verify), reference data (schemas, regimes,
// addons), key generation, the OpenAPI spec, and a version/health endpoint.
// correct, replicate, sign, verify, import, export), reference data (schemas,
// regimes, addons, formats), key generation, the OpenAPI spec, and a version/health endpoint.
// CORS, version-header, timing, and logging middleware are always applied.
//
// Use [WithMCP], [WithFavicon], and [WithRoutes] to enable additional
Expand All @@ -75,6 +75,9 @@ func NewHandler(opts ...Option) http.Handler {
mux.HandleFunc("POST "+p+"/replicate", handleReplicate)
mux.HandleFunc("POST "+p+"/sign", handleSign)
mux.HandleFunc("POST "+p+"/verify", handleVerify)
mux.HandleFunc("POST "+p+"/import", handleImport)
mux.HandleFunc("POST "+p+"/export", handleExport)
mux.HandleFunc("POST "+p+"/formats", handleFormatCheck)

// Reference data (static per version — ETag for caching)
mux.HandleFunc("GET "+p+"/schemas", WithETag(handleSchemaList))
Expand All @@ -83,6 +86,7 @@ func NewHandler(opts ...Option) http.Handler {
mux.HandleFunc("GET "+p+"/regimes/{code}", WithETag(handleRegime))
mux.HandleFunc("GET "+p+"/addons", WithETag(handleAddonList))
mux.HandleFunc("GET "+p+"/addons/{key...}", WithETag(handleAddon))
mux.HandleFunc("GET "+p+"/formats", WithETag(handleFormatList))

// MCP (Streamable HTTP — stateless)
if cfg.withMCP {
Expand Down Expand Up @@ -203,6 +207,7 @@ func withCORS(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Expose-Headers", "GOBL-Version, "+FormatHeader)

if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
Expand Down
79 changes: 79 additions & 0 deletions api/convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package api

import (
"net/http"
"strings"

"github.com/invopop/gobl.dev/internal/ops"
"github.com/invopop/gobl/cbc"
)

// FormatHeader is the response header that names the key of the format an
// export was converted into, or an import was detected as.
const FormatHeader = "GOBL-Format"

func handleFormatList(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, map[string]any{"formats": ops.Formats()})
}

// handleFormatCheck reports, for each format that exports the schema of the
// GOBL document in the body, whether it accepts the document.
func handleFormatCheck(w http.ResponseWriter, r *http.Request) {
checks, err := ops.CheckFormats(r.Context(), &ops.ParseOptions{
Input: r.Body,
})
if err != nil {
WriteError(w, err)
return
}
WriteJSON(w, map[string]any{"formats": checks})
}

func handleImport(w http.ResponseWriter, r *http.Request) {
env, f, err := ops.Import(r.Context(), &ops.ImportOptions{
Input: r.Body,
Formats: queryFormats(r),
})
if err != nil {
WriteError(w, err)
return
}
w.Header().Set(FormatHeader, f.Key.String())
WriteJSON(w, env)
}

func handleExport(w http.ResponseWriter, r *http.Request) {
out, err := ops.Export(r.Context(), &ops.ExportOptions{
ParseOptions: &ops.ParseOptions{
Input: r.Body,
},
Formats: queryFormats(r),
})
if err != nil {
WriteError(w, err)
return
}
mime := out.Format.MIME
if mime == "" {
mime = "application/octet-stream"
}
w.Header().Set("Content-Type", mime)
w.Header().Set(FormatHeader, out.Format.Key.String())
_, _ = w.Write(out.Data) //nolint:errcheck
}

// queryFormats reads the format keys from the "format" query parameter, which
// may be repeated or hold a comma-separated list, preserving their order.
// Keys contain "+", which query strings decode as a space, so unescaped keys
// such as "ubl+en16931-v2017" are restored.
func queryFormats(r *http.Request) []cbc.Key {
var keys []cbc.Key
for _, v := range r.URL.Query()["format"] {
for _, k := range strings.Split(v, ",") {
if k = strings.ReplaceAll(strings.TrimSpace(k), " ", "+"); k != "" {
keys = append(keys, cbc.Key(k))
}
}
}
return keys
}
149 changes: 149 additions & 0 deletions api/convert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package api_test

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/invopop/gobl"
"github.com/invopop/gobl.dev/api"
"github.com/invopop/gobl/bill"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestFormatListEndpoint(t *testing.T) {
srv := httptest.NewServer(api.NewHandler())
defer srv.Close()

resp, err := http.Get(srv.URL + prefix + "/formats")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)

var body struct {
Formats []struct {
Key string `json:"key"`
} `json:"formats"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
keys := make([]string, len(body.Formats))
for i, f := range body.Formats {
keys[i] = f.Key
}
assert.Contains(t, keys, "ubl+en16931-v2017")
assert.Contains(t, keys, "cii+en16931-v2017")
}

func TestExportImportEndpoints(t *testing.T) {
srv := httptest.NewServer(api.NewHandler())
defer srv.Close()

doc, err := os.ReadFile("../internal/ops/testdata/convert-invoice.json")
require.NoError(t, err)

resp, err := http.Post(srv.URL+prefix+"/export?format=foo+bar,cii%2Ben16931-v2017", "application/json", strings.NewReader(string(doc)))
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, "unknown keys are rejected")

// The "+" in keys may be left unescaped.
resp, err = http.Post(srv.URL+prefix+"/export?format=ubl+en16931-v2017", "application/json", strings.NewReader(string(doc)))
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "ubl+en16931-v2017", resp.Header.Get(api.FormatHeader))
assert.Contains(t, resp.Header.Get("Content-Type"), "xml")
xml, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(xml), "<Invoice")

resp, err = http.Post(srv.URL+prefix+"/import", "application/xml", strings.NewReader(string(xml)))
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "ubl+en16931-v2017", resp.Header.Get(api.FormatHeader), "detected format")
env := new(gobl.Envelope)
require.NoError(t, json.NewDecoder(resp.Body).Decode(env))
inv, ok := env.Extract().(*bill.Invoice)
require.True(t, ok)
assert.Equal(t, "Provide One GmbH", inv.Supplier.Name)
}

func TestExportEndpointErrors(t *testing.T) {
srv := httptest.NewServer(api.NewHandler())
defer srv.Close()

t.Run("no format", func(t *testing.T) {
resp, err := http.Post(srv.URL+prefix+"/export", "application/json", strings.NewReader(string(testInvoice)))
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})

t.Run("not supported", func(t *testing.T) {
resp, err := http.Post(srv.URL+prefix+"/export?format=ubl%2Ben16931-v2017", "application/json", strings.NewReader(string(testInvoice)))
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Equal(t, "not-supported", body["key"])
})
}

func TestImportEndpointUnknown(t *testing.T) {
srv := httptest.NewServer(api.NewHandler())
defer srv.Close()

resp, err := http.Post(srv.URL+prefix+"/import", "application/xml", strings.NewReader("<foo/>"))
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
}

func TestFormatCheckEndpoint(t *testing.T) {
srv := httptest.NewServer(api.NewHandler())
defer srv.Close()

doc, err := os.ReadFile("../internal/ops/testdata/convert-invoice.json")
require.NoError(t, err)

resp, err := http.Post(srv.URL+prefix+"/formats", "application/json", strings.NewReader(string(doc)))
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)

var body struct {
Formats []struct {
Key string `json:"key"`
Accepted bool `json:"accepted"`
Error *struct {
Key string `json:"key"`
Faults []map[string]any `json:"faults"`
} `json:"error"`
} `json:"formats"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
got := make(map[string]bool)
for _, f := range body.Formats {
got[f.Key] = f.Accepted
if f.Key == "ubl+de-xrechnung-v3" {
require.NotNil(t, f.Error)
assert.Equal(t, "not-supported", f.Error.Key)
assert.NotEmpty(t, f.Error.Faults, "refusals are given as faults")
}
}
assert.True(t, got["ubl+en16931-v2017"])
assert.False(t, got["ubl+de-xrechnung-v3"])
assert.NotContains(t, got, "cdar+fr-ctc-cdv-v1", "formats that do not export invoices are left out")

resp, err = http.Post(srv.URL+prefix+"/formats", "application/json", strings.NewReader(`{"$schema": "https://gobl.org/draft-0/bill/invoice"}`))
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, "documents that do not build are rejected")
}
Loading
Loading