From b3385d0ae8bd17ce7e59caa0470d7ef34441278a Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Mon, 22 Jun 2026 23:58:47 +0200 Subject: [PATCH 1/7] feat!: require project id and move client API under /projects/{projectID} The Lunogram Client API now scopes every endpoint to a project via the URL: /api/client/... becomes /api/client/projects/{projectID}/.... NewClient now takes a required projectID (a UUID) and returns an error if it is empty or malformed. The project segment is injected centrally in the transport (clientPath), so resource methods pass project-relative paths. This is a hard, intentional breaking change with no backwards compatibility. It depends on the corresponding platform change that requires the project in the URL, and should merge once that ships. --- README.md | 25 ++++++++++++++++++---- client.go | 51 +++++++++++++++++++++++++++++++++++++-------- integration_test.go | 15 ++++++++++--- organizations.go | 10 ++++----- scheduled.go | 8 +++---- users.go | 6 +++--- 6 files changed, 87 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e2b32a9..ccc36d7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,16 @@ go get github.com/lunogram/go-sdk Requires **Go 1.22** or later. +## Project scoping + +Every Client API request is scoped to a single Lunogram project. You supply the +project ID (a UUID) once, when constructing the client, and the SDK injects it +into every request path automatically — you never pass it per call. Requests are +issued under `/api/client/projects/{projectID}/...`. + +`NewClient` validates the project ID and returns an error if it is empty or not +a valid UUID. + ## Quick start ```go @@ -26,7 +36,11 @@ import ( ) func main() { - client := lunogram.NewClient("your-api-key") + // The project ID (a UUID) scopes every request. It is required. + client, err := lunogram.NewClient("your-api-key", "your-project-id") + if err != nil { + log.Fatal(err) + } ctx := context.Background() user, err := client.UpsertUser(ctx, &lunogram.UpsertUserRequest{ @@ -48,18 +62,21 @@ func main() { ## Client options +All options are passed after the required `apiKey` and `projectID` arguments, +and `NewClient` returns `(*Client, error)`. + ```go // Custom base URL (e.g. for self-hosted or staging environments) -client := lunogram.NewClient(apiKey, lunogram.WithBaseURL("https://outreach.internal")) +client, err := lunogram.NewClient(apiKey, projectID, lunogram.WithBaseURL("https://outreach.internal")) // Custom HTTP client -client := lunogram.NewClient(apiKey, lunogram.WithHTTPClient(&http.Client{ +client, err := lunogram.NewClient(apiKey, projectID, lunogram.WithHTTPClient(&http.Client{ Timeout: 10 * time.Second, Transport: myTransport, })) // Custom timeout (applies to the default HTTP client) -client := lunogram.NewClient(apiKey, lunogram.WithTimeout(10*time.Second)) +client, err := lunogram.NewClient(apiKey, projectID, lunogram.WithTimeout(10*time.Second)) ``` ## Users diff --git a/client.go b/client.go index 17d82e5..c3b7a88 100644 --- a/client.go +++ b/client.go @@ -1,9 +1,14 @@ // Package lunogram provides a Go client for the Lunogram Client API. // -// Create a client with your API key and use it to manage users, -// organizations, events, and scheduled resources in your Lunogram project. +// Create a client with your API key and project ID, then use it to manage +// users, organizations, events, and scheduled resources in that project. +// Every Client API request is scoped to the project supplied at construction; +// the SDK injects the project into the request path automatically. // -// client := lunogram.NewClient("your-api-key") +// client, err := lunogram.NewClient("your-api-key", "your-project-id") +// if err != nil { +// // handle invalid project ID +// } // user, err := client.UpsertUser(ctx, &lunogram.UpsertUserRequest{...}) package lunogram @@ -14,6 +19,7 @@ import ( "fmt" "io" "net/http" + "regexp" "time" ) @@ -22,9 +28,13 @@ const ( defaultTimeout = 30 * time.Second ) +// uuidPattern matches a canonical RFC 4122 UUID (e.g. a project ID). +var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + // Client communicates with the Lunogram Client API. type Client struct { apiKey string + projectID string baseURL string httpClient *http.Client } @@ -53,11 +63,23 @@ func WithTimeout(d time.Duration) Option { } } -// NewClient creates a Lunogram client authenticated with the given API key. -func NewClient(apiKey string, opts ...Option) *Client { +// NewClient creates a Lunogram client authenticated with the given API key and +// scoped to the given project. projectID must be a non-empty UUID; every Client +// API request is issued under /api/client/projects/{projectID}/. +// +// It returns an error if projectID is empty or not a valid UUID. +func NewClient(apiKey, projectID string, opts ...Option) (*Client, error) { + if projectID == "" { + return nil, fmt.Errorf("lunogram: projectID is required") + } + if !uuidPattern.MatchString(projectID) { + return nil, fmt.Errorf("lunogram: projectID %q is not a valid UUID", projectID) + } + c := &Client{ - apiKey: apiKey, - baseURL: defaultBaseURL, + apiKey: apiKey, + projectID: projectID, + baseURL: defaultBaseURL, httpClient: &http.Client{ Timeout: defaultTimeout, }, @@ -65,7 +87,7 @@ func NewClient(apiKey string, opts ...Option) *Client { for _, opt := range opts { opt(c) } - return c + return c, nil } // APIError is returned when the API responds with a non-success status code. @@ -82,8 +104,19 @@ func (e *APIError) Error() string { return fmt.Sprintf("lunogram: %s (%d)", e.Title, e.StatusCode) } +// clientPath builds a fully-qualified Client API path for this client's +// project. path is the portion after the project segment and must begin with a +// slash (e.g. "/users", "/organizations/events"). The result is +// "/api/client/projects/{projectID}{path}". +func (c *Client) clientPath(path string) string { + return "/api/client/projects/" + c.projectID + path +} + // do executes an HTTP request and decodes the JSON response into dst. // If dst is nil the response body is discarded (used for 202/204 responses). +// +// path is relative to the project's Client API root (e.g. "/users"); the +// project segment is injected automatically via clientPath. func (c *Client) do(ctx context.Context, method, path string, body any, dst any) error { var reqBody io.Reader if body != nil { @@ -94,7 +127,7 @@ func (c *Client) do(ctx context.Context, method, path string, body any, dst any) reqBody = bytes.NewReader(b) } - req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+c.clientPath(path), reqBody) if err != nil { return fmt.Errorf("lunogram: build request: %w", err) } diff --git a/integration_test.go b/integration_test.go index 5c4c87b..ed4eddd 100644 --- a/integration_test.go +++ b/integration_test.go @@ -15,23 +15,32 @@ import ( // // Required environment variables: // -// LUNOGRAM_API_KEY – project-scoped API key -// LUNOGRAM_API_URL – base URL of the Lunogram API (e.g. "https://lunogram.com") +// LUNOGRAM_API_KEY – project-scoped API key +// LUNOGRAM_PROJECT_ID – UUID of the Lunogram project to operate on +// LUNOGRAM_API_URL – base URL of the Lunogram API (e.g. "https://lunogram.com") func testClient(t *testing.T) *lunogram.Client { t.Helper() apiKey := os.Getenv("LUNOGRAM_API_KEY") + projectID := os.Getenv("LUNOGRAM_PROJECT_ID") urlEndpoint := os.Getenv("LUNOGRAM_API_URL") if apiKey == "" { t.Skip("LUNOGRAM_API_KEY is not set; skipping integration test") } + if projectID == "" { + t.Skip("LUNOGRAM_PROJECT_ID is not set; skipping integration test") + } if urlEndpoint == "" { t.Skip("LUNOGRAM_API_URL is not set; skipping integration test") } require.NotEmpty(t, apiKey, "LUNOGRAM_API_KEY must not be empty") + require.NotEmpty(t, projectID, "LUNOGRAM_PROJECT_ID must not be empty") require.NotEmpty(t, urlEndpoint, "LUNOGRAM_API_URL must not be empty") - return lunogram.NewClient(apiKey, lunogram.WithBaseURL(urlEndpoint)) + client, err := lunogram.NewClient(apiKey, projectID, lunogram.WithBaseURL(urlEndpoint)) + require.NoError(t, err, "NewClient should succeed") + + return client } diff --git a/organizations.go b/organizations.go index 651ecd4..29e5208 100644 --- a/organizations.go +++ b/organizations.go @@ -8,7 +8,7 @@ import ( // UpsertOrganization creates or updates an organization by external identifier(s). func (c *Client) UpsertOrganization(ctx context.Context, req *UpsertOrganizationRequest) (*Organization, error) { var org Organization - if err := c.do(ctx, http.MethodPost, "/api/client/organizations", req, &org); err != nil { + if err := c.do(ctx, http.MethodPost, "/organizations", req, &org); err != nil { return nil, err } return &org, nil @@ -16,23 +16,23 @@ func (c *Client) UpsertOrganization(ctx context.Context, req *UpsertOrganization // DeleteOrganization deletes an organization and removes all its memberships. func (c *Client) DeleteOrganization(ctx context.Context, req *DeleteOrganizationRequest) error { - return c.do(ctx, http.MethodDelete, "/api/client/organizations", req, nil) + return c.do(ctx, http.MethodDelete, "/organizations", req, nil) } // AddOrganizationUser adds a user to an organization with optional // organization-specific data (e.g. role, department). func (c *Client) AddOrganizationUser(ctx context.Context, req *OrganizationUserRequest) error { - return c.do(ctx, http.MethodPost, "/api/client/organizations/users", req, nil) + return c.do(ctx, http.MethodPost, "/organizations/users", req, nil) } // RemoveOrganizationUser removes a user from an organization. func (c *Client) RemoveOrganizationUser(ctx context.Context, req *RemoveOrganizationUserRequest) error { - return c.do(ctx, http.MethodDelete, "/api/client/organizations/users", req, nil) + return c.do(ctx, http.MethodDelete, "/organizations/users", req, nil) } // PostOrganizationEvents sends one or more events for organizations. // Events are processed asynchronously and can trigger journeys for all // users in the targeted organization(s). func (c *Client) PostOrganizationEvents(ctx context.Context, events []OrganizationEvent) error { - return c.do(ctx, http.MethodPost, "/api/client/organizations/events", events, nil) + return c.do(ctx, http.MethodPost, "/organizations/events", events, nil) } diff --git a/scheduled.go b/scheduled.go index 9fd1abf..285374b 100644 --- a/scheduled.go +++ b/scheduled.go @@ -10,7 +10,7 @@ import ( // on the scheduled resource. func (c *Client) UpsertUserScheduled(ctx context.Context, req *UpsertUserScheduledRequest) (*ScheduledAccepted, error) { var accepted ScheduledAccepted - if err := c.do(ctx, http.MethodPost, "/api/client/users/scheduled", req, &accepted); err != nil { + if err := c.do(ctx, http.MethodPost, "/users/scheduled", req, &accepted); err != nil { return nil, err } return &accepted, nil @@ -19,7 +19,7 @@ func (c *Client) UpsertUserScheduled(ctx context.Context, req *UpsertUserSchedul // DeleteUserScheduled deletes all scheduled instances for a user with // the given resource name. This cancels any pending journey entrance states. func (c *Client) DeleteUserScheduled(ctx context.Context, req *DeleteUserScheduledRequest) error { - return c.do(ctx, http.MethodDelete, "/api/client/users/scheduled", req, nil) + return c.do(ctx, http.MethodDelete, "/users/scheduled", req, nil) } // UpsertOrganizationScheduled creates or updates a scheduled resource for @@ -27,7 +27,7 @@ func (c *Client) DeleteUserScheduled(ctx context.Context, req *DeleteUserSchedul // journeys that depend on the scheduled resource. func (c *Client) UpsertOrganizationScheduled(ctx context.Context, req *UpsertOrganizationScheduledRequest) (*ScheduledAccepted, error) { var accepted ScheduledAccepted - if err := c.do(ctx, http.MethodPost, "/api/client/organizations/scheduled", req, &accepted); err != nil { + if err := c.do(ctx, http.MethodPost, "/organizations/scheduled", req, &accepted); err != nil { return nil, err } return &accepted, nil @@ -37,5 +37,5 @@ func (c *Client) UpsertOrganizationScheduled(ctx context.Context, req *UpsertOrg // organization with the given resource name. This cancels any pending // journey entrance states. func (c *Client) DeleteOrganizationScheduled(ctx context.Context, req *DeleteOrganizationScheduledRequest) error { - return c.do(ctx, http.MethodDelete, "/api/client/organizations/scheduled", req, nil) + return c.do(ctx, http.MethodDelete, "/organizations/scheduled", req, nil) } diff --git a/users.go b/users.go index c26b552..938724b 100644 --- a/users.go +++ b/users.go @@ -10,7 +10,7 @@ import ( // already exists, it is updated; otherwise a new user is created. func (c *Client) UpsertUser(ctx context.Context, req *UpsertUserRequest) (*User, error) { var user User - if err := c.do(ctx, http.MethodPost, "/api/client/users", req, &user); err != nil { + if err := c.do(ctx, http.MethodPost, "/users", req, &user); err != nil { return nil, err } return &user, nil @@ -18,7 +18,7 @@ func (c *Client) UpsertUser(ctx context.Context, req *UpsertUserRequest) (*User, // DeleteUser deletes a user by their external identifier(s). func (c *Client) DeleteUser(ctx context.Context, req *DeleteUserRequest) error { - return c.do(ctx, http.MethodDelete, "/api/client/users", req, nil) + return c.do(ctx, http.MethodDelete, "/users", req, nil) } // PostUserEvents sends one or more events for users. Events are processed @@ -26,5 +26,5 @@ func (c *Client) DeleteUser(ctx context.Context, req *DeleteUserRequest) error { // // Each event is handled independently: if one fails, others may still succeed. func (c *Client) PostUserEvents(ctx context.Context, events []Event) error { - return c.do(ctx, http.MethodPost, "/api/client/users/events", events, nil) + return c.do(ctx, http.MethodPost, "/users/events", events, nil) } From 2da201e1ea0bc458208965ddf2e2c73cc4535a84 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Tue, 23 Jun 2026 09:56:08 +0200 Subject: [PATCH 2/7] refactor: validate projectID with google/uuid instead of a regex --- client.go | 10 ++++------ go.mod | 5 ++++- go.sum | 2 ++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/client.go b/client.go index c3b7a88..c3bf228 100644 --- a/client.go +++ b/client.go @@ -19,8 +19,9 @@ import ( "fmt" "io" "net/http" - "regexp" "time" + + "github.com/google/uuid" ) const ( @@ -28,9 +29,6 @@ const ( defaultTimeout = 30 * time.Second ) -// uuidPattern matches a canonical RFC 4122 UUID (e.g. a project ID). -var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) - // Client communicates with the Lunogram Client API. type Client struct { apiKey string @@ -72,8 +70,8 @@ func NewClient(apiKey, projectID string, opts ...Option) (*Client, error) { if projectID == "" { return nil, fmt.Errorf("lunogram: projectID is required") } - if !uuidPattern.MatchString(projectID) { - return nil, fmt.Errorf("lunogram: projectID %q is not a valid UUID", projectID) + if _, err := uuid.Parse(projectID); err != nil { + return nil, fmt.Errorf("lunogram: projectID %q is not a valid UUID: %w", projectID, err) } c := &Client{ diff --git a/go.mod b/go.mod index 0807cec..ae4693e 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/lunogram/go-sdk go 1.23.0 -require github.com/stretchr/testify v1.11.1 +require ( + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 +) tool github.com/golangci/golangci-lint/cmd/golangci-lint diff --git a/go.sum b/go.sum index 2decb8c..5578415 100644 --- a/go.sum +++ b/go.sum @@ -274,6 +274,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= From 6a09865ea3ba4388f787e8ccdee00f2df8836a7d Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Tue, 23 Jun 2026 11:30:37 +0200 Subject: [PATCH 3/7] refactor!: generate the client types from the OpenAPI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the SDK to spec-driven code generation, mirroring the js-sdk template. The low-level type layer is now generated from the platform's vendored OpenAPI spec; the hand-written facade (NewClient, functional options, the /api/client/projects/{projectID} prefix, google/uuid validation, Bearer auth) stays single-point and unchanged. Layout: - spec/client.yaml: vendored, pinned spec (see spec/SOURCE.md for the source repo, spec path, and pinned ref). No platform release needed — any ref is fetchable via the raw URL. - gen/: spec-generated types (client.gen.go, DO-NOT-EDIT) via oapi-codegen v2.7.0, wired through go:generate and a go.mod tool dependency so `go generate` is deterministic with no separate install. gen/types.go is a hand-written companion for the spec's x-go-type references (pagination params), mirroring the platform's own pattern. - conformance_test.go: asserts every facade request/response type shares the exact JSON wire shape of its generated spec counterpart, so a spec change surfaces as a failing test rather than a silent runtime mismatch. CI: - ci.yml: regenerate from the spec and fail on drift (git diff gen), then build, vet, gofmt, and test. - spec-sync.yml: weekly cron + manual; re-fetch the spec at the pin in spec/SOURCE.md, regenerate, and open a PR on change. The public API is unchanged from #2; the ! marks the build-system and dependency shift (go.mod tool directive, generated low-level layer). --- .github/workflows/ci.yml | 52 + .github/workflows/spec-sync.yml | 78 ++ Makefile | 4 + conformance_test.go | 232 ++++ gen/client.gen.go | 664 ++++++++++++ gen/config.yaml | 11 + gen/gen.go | 12 + gen/types.go | 14 + go.mod | 45 +- go.sum | 117 +- spec/SOURCE.md | 38 + spec/client.yaml | 1779 +++++++++++++++++++++++++++++++ 12 files changed, 3020 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/spec-sync.yml create mode 100644 conformance_test.go create mode 100644 gen/client.gen.go create mode 100644 gen/config.yaml create mode 100644 gen/gen.go create mode 100644 gen/types.go create mode 100644 spec/SOURCE.md create mode 100644 spec/client.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..58a147d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main, feat/project-in-url] + +permissions: + contents: read + +jobs: + build: + name: Build & verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: "1.25" + + # Drift check: regenerate the low-level layer from the vendored spec and + # fail if the committed output is stale. The generated code under gen/ + # must always match spec/client.yaml. oapi-codegen is pinned as a tool + # dependency in go.mod, so `go generate` resolves it deterministically. + - name: Regenerate from spec + run: go generate ./... + - name: Verify gen/ is up to date + run: | + git diff --exit-code gen || { + echo "::error::Generated code in gen/ is out of date. Run 'make generate' and commit the result." + exit 1 + } + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Check formatting + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "::error::These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: Test + run: go test -count=1 -race ./... diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml new file mode 100644 index 0000000..588389f --- /dev/null +++ b/.github/workflows/spec-sync.yml @@ -0,0 +1,78 @@ +name: Spec Sync + +# Bump bot for the vendored OpenAPI spec. +# +# Re-fetches spec/client.yaml from the pinned ref recorded in spec/SOURCE.md, +# regenerates the low-level layer (gen/), and — if anything changed — opens a +# PR. This keeps the SDK in step with the platform spec without a manual fetch. +# +# Note: this syncs *at the current pin*. Bumping the pin itself (e.g. to a new +# release tag) is a manual edit to spec/SOURCE.md (its raw URL is re-read from +# that file each run, so a bumped pin is picked up automatically). + +on: + schedule: + # Weekly, Monday 06:00 UTC. + - cron: "0 6 * * 1" + workflow_dispatch: {} + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + name: Re-fetch spec & regenerate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: "1.25" + + # The raw URL (repo + spec path + pinned ref) is single-sourced in + # spec/SOURCE.md; read it from the fenced ```...``` block there so the pin + # only ever lives in one place. + - name: Resolve spec URL from SOURCE.md + id: source + run: | + url="$(grep -Eo 'https://raw\.githubusercontent\.com/[^ )]+resources\.yml' spec/SOURCE.md | head -n1)" + if [ -z "$url" ]; then + echo "::error::Could not find the raw spec URL in spec/SOURCE.md" + exit 1 + fi + echo "Spec URL: $url" + echo "url=$url" >> "$GITHUB_OUTPUT" + + - name: Re-fetch spec + run: curl -fsSL "${{ steps.source.outputs.url }}" -o spec/client.yaml + + - name: Regenerate low-level layer + run: go generate ./... + + - name: Verify it still builds + run: go build ./... + + # Open (or update) a PR only when the vendored spec or generated output + # actually changed. + - name: Open PR on drift + uses: peter-evans/create-pull-request@v7 + with: + add-paths: | + spec/client.yaml + gen/** + branch: chore/spec-sync + commit-message: "chore: sync OpenAPI spec & regenerate client layer" + title: "chore: sync OpenAPI spec & regenerate client layer" + body: | + Automated spec sync. + + Re-fetched `spec/client.yaml` from the pinned ref in `spec/SOURCE.md` + and regenerated `gen/` via `go generate ./...`. + + Review the diff and merge if the changes look correct. If the spec + should track a different ref, update `spec/SOURCE.md` first. + labels: | + dependencies + automated diff --git a/Makefile b/Makefile index e499c52..21724a3 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,10 @@ $(BIN)/%: | $(BIN) ; $(info $(M) building $(@F)…) GOLANGCI_LINT = $(BIN)/golangci-lint # Targets +.PHONY: generate +generate: | ; $(info $(M) generating spec-driven types…) @ ## Regenerate gen/ from spec/client.yaml + $Q $(GO) generate ./... + .PHONY: lint lint: | $(GOLANGCI_LINT) ; $(info $(M) running linters…) @ ## Run the project linters $Q $(GOLANGCI_LINT) run --max-issues-per-linter 10 --timeout 5m diff --git a/conformance_test.go b/conformance_test.go new file mode 100644 index 0000000..531745b --- /dev/null +++ b/conformance_test.go @@ -0,0 +1,232 @@ +package lunogram + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/lunogram/go-sdk/gen" +) + +// The SDK is layered: gen/ holds the spec-driven types generated from the +// vendored OpenAPI spec (spec/client.yaml), and the root package is a +// hand-written facade that keeps an ergonomic, stable public API on top of +// them. The facade types are not aliases of the generated types — they are +// hand-tuned for ergonomics (value strings/maps, the String helper) — so this +// test is the contract that keeps the two layers in lockstep with the spec. +// +// For each facade request/response type it marshals a populated value and +// unmarshals the JSON into the corresponding generated type (and back), failing +// if the wire shape diverges. Because the generated types are regenerated from +// the spec on every build (the CI drift check), a spec change that alters a +// field name, JSON tag, or type surfaces here as a failing test rather than as +// a silent wire-format mismatch at runtime. +// +// These tests run on every `go test` (they need no API credentials), unlike the +// integration tests in the *_test.go files which skip without a live backend. + +// roundTrip marshals src (a facade value) to JSON, unmarshals it into dst (a +// pointer to the corresponding generated type), then marshals dst again and +// asserts the two encodings carry the same data. Object key order is ignored — +// oapi-codegen alphabetizes struct fields, which is not a wire difference — so +// the encodings are compared as decoded JSON values rather than as raw bytes. +// +// This proves the facade type and the generated type share the same JSON wire +// shape: same field set, same JSON tags, same value encodings. +func roundTrip(t *testing.T, name string, src, dst any) { + t.Helper() + + srcJSON, err := json.Marshal(src) + if err != nil { + t.Fatalf("%s: marshal facade type: %v", name, err) + } + if err := json.Unmarshal(srcJSON, dst); err != nil { + t.Fatalf("%s: unmarshal into generated type: %v", name, err) + } + dstJSON, err := json.Marshal(dst) + if err != nil { + t.Fatalf("%s: re-marshal generated type: %v", name, err) + } + if !jsonEqual(t, srcJSON, dstJSON) { + t.Errorf("%s: wire shape drift between facade and generated type\n facade: %s\n generated: %s", + name, srcJSON, dstJSON) + } +} + +// jsonEqual reports whether two JSON encodings represent the same value, +// independent of object key ordering. +func jsonEqual(t *testing.T, a, b []byte) bool { + t.Helper() + if bytes.Equal(a, b) { + return true + } + var av, bv any + if err := json.Unmarshal(a, &av); err != nil { + t.Fatalf("unmarshal a: %v", err) + } + if err := json.Unmarshal(b, &bv); err != nil { + t.Fatalf("unmarshal b: %v", err) + } + return reflect.DeepEqual(av, bv) +} + +func TestFacadeTypesConformToSpec(t *testing.T) { + t.Parallel() + + at := time.Date(2025, 12, 25, 10, 0, 0, 0, time.UTC) + + t.Run("UpsertUserRequest", func(t *testing.T) { + roundTrip(t, "UpsertUserRequest", &UpsertUserRequest{ + Identifier: []ExternalID{{Source: "default", ExternalID: "u1", Metadata: map[string]any{"k": "v"}}}, + Email: String("u@example.com"), + Phone: String("+31612345678"), + Timezone: String("Europe/Amsterdam"), + Locale: String("nl-NL"), + Data: map[string]any{"plan": "pro"}, + }, &gen.IdentifyRequest{}) + }) + + t.Run("DeleteUserRequest", func(t *testing.T) { + roundTrip(t, "DeleteUserRequest", &DeleteUserRequest{ + Identifier: []ExternalID{{ExternalID: "u1"}}, + }, &gen.DeleteUserRequest{}) + }) + + t.Run("Event", func(t *testing.T) { + roundTrip(t, "Event", &Event{ + Name: "purchase_completed", + Identifier: []ExternalID{{ExternalID: "u1"}}, + Data: map[string]any{"amount": 49.99}, + }, &gen.Event{}) + }) + + t.Run("UpsertOrganizationRequest", func(t *testing.T) { + roundTrip(t, "UpsertOrganizationRequest", &UpsertOrganizationRequest{ + Identifier: []ExternalID{{ExternalID: "o1"}}, + Name: String("Acme Corp"), + Data: map[string]any{"industry": "tech"}, + }, &gen.OrganizationRequest{}) + }) + + t.Run("DeleteOrganizationRequest", func(t *testing.T) { + roundTrip(t, "DeleteOrganizationRequest", &DeleteOrganizationRequest{ + Identifier: []ExternalID{{ExternalID: "o1"}}, + }, &gen.DeleteOrganizationRequest{}) + }) + + t.Run("OrganizationUserRequest", func(t *testing.T) { + roundTrip(t, "OrganizationUserRequest", &OrganizationUserRequest{ + Organization: OrganizationRef{Identifier: []ExternalID{{ExternalID: "o1"}}}, + User: UserRef{Identifier: []ExternalID{{ExternalID: "u1"}}}, + Data: map[string]any{"role": "admin"}, + }, &gen.OrganizationUserRequest{}) + }) + + t.Run("RemoveOrganizationUserRequest", func(t *testing.T) { + roundTrip(t, "RemoveOrganizationUserRequest", &RemoveOrganizationUserRequest{ + Organization: OrganizationRef{Identifier: []ExternalID{{ExternalID: "o1"}}}, + User: UserRef{Identifier: []ExternalID{{ExternalID: "u1"}}}, + }, &gen.RemoveOrganizationUserRequest{}) + }) + + t.Run("OrganizationEvent", func(t *testing.T) { + roundTrip(t, "OrganizationEvent", &OrganizationEvent{ + Name: "subscription_upgraded", + Identifier: []ExternalID{{ExternalID: "o1"}}, + Data: map[string]any{"to_plan": "enterprise"}, + }, &gen.OrganizationEvent{}) + }) + + t.Run("UpsertUserScheduledRequest", func(t *testing.T) { + roundTrip(t, "UpsertUserScheduledRequest", &UpsertUserScheduledRequest{ + Name: "renewal_date", + Identifier: []ExternalID{{ExternalID: "u1"}}, + ScheduledAt: &at, + Data: map[string]any{"plan": "pro"}, + }, &gen.UpsertUserScheduledRequest{}) + }) + + t.Run("DeleteUserScheduledRequest", func(t *testing.T) { + roundTrip(t, "DeleteUserScheduledRequest", &DeleteUserScheduledRequest{ + Name: "renewal_date", + Identifier: []ExternalID{{ExternalID: "u1"}}, + }, &gen.DeleteUserScheduledRequest{}) + }) + + t.Run("UpsertOrganizationScheduledRequest", func(t *testing.T) { + roundTrip(t, "UpsertOrganizationScheduledRequest", &UpsertOrganizationScheduledRequest{ + Name: "contract_renewal", + Identifier: []ExternalID{{ExternalID: "o1"}}, + ScheduledAt: &at, + Data: map[string]any{"seats": 100}, + }, &gen.UpsertOrganizationScheduledRequest{}) + }) + + t.Run("DeleteOrganizationScheduledRequest", func(t *testing.T) { + roundTrip(t, "DeleteOrganizationScheduledRequest", &DeleteOrganizationScheduledRequest{ + Name: "contract_renewal", + Identifier: []ExternalID{{ExternalID: "o1"}}, + }, &gen.DeleteOrganizationScheduledRequest{}) + }) + + t.Run("ScheduledAccepted", func(t *testing.T) { + roundTrip(t, "ScheduledAccepted", &ScheduledAccepted{ + ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Name: "trial_end", + ScheduledAt: at, + Data: map[string]any{"k": "v"}, + }, &gen.ScheduledAccepted{}) + }) + + // Response types. The facade decodes API responses into these, so they must + // match the generated shapes the server emits. The facade carries IDs as + // strings where the generated types use openapi_types.UUID; a UUID marshals + // to the same JSON string, so the wire shape is identical. + t.Run("ExternalIDResponse", func(t *testing.T) { + roundTrip(t, "ExternalIDResponse", &ExternalIDResponse{ + ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Source: "default", + ExternalID: "u1", + Metadata: map[string]any{"k": "v"}, + CreatedAt: at, + UpdatedAt: at, + }, &gen.ExternalIDResponse{}) + }) + + t.Run("User", func(t *testing.T) { + roundTrip(t, "User", &User{ + ID: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + ProjectID: "4c9d3163-7b64-4f9e-9068-d2e4b96be56b", + Identifier: []ExternalIDResponse{{ + ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", Source: "default", + ExternalID: "u1", CreatedAt: at, UpdatedAt: at, + }}, + Email: "u@example.com", + Data: map[string]any{"plan": "pro"}, + Timezone: "Europe/Amsterdam", + Locale: "nl-NL", + HasPushDevice: true, + Version: 1, + CreatedAt: at, + UpdatedAt: at, + }, &gen.User{}) + }) + + t.Run("Organization", func(t *testing.T) { + roundTrip(t, "Organization", &Organization{ + ID: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + ProjectID: "4c9d3163-7b64-4f9e-9068-d2e4b96be56b", + Identifier: []ExternalIDResponse{{ + ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", Source: "default", + ExternalID: "o1", CreatedAt: at, UpdatedAt: at, + }}, + Name: "Acme Corp", + Data: map[string]any{"industry": "tech"}, + Version: 1, + CreatedAt: at, + UpdatedAt: at, + }, &gen.Organization{}) + }) +} diff --git a/gen/client.gen.go b/gen/client.gen.go new file mode 100644 index 0000000..69e9ff5 --- /dev/null +++ b/gen/client.gen.go @@ -0,0 +1,664 @@ +// Package gen provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.0 DO NOT EDIT. +package gen + +import ( + "encoding/json" + "time" + + openapi_types "github.com/oapi-codegen/runtime/types" +) + +const ( + HttpBearerAuthScopes hTTPBearerAuthContextKey = "HttpBearerAuth.Scopes" +) + +// Defines values for Channel. +const ( + Email Channel = "email" + Inbox Channel = "inbox" + Push Channel = "push" + Sms Channel = "sms" +) + +// Valid indicates whether the value is a known member of the Channel enum. +func (e Channel) Valid() bool { + switch e { + case Email: + return true + case Inbox: + return true + case Push: + return true + case Sms: + return true + default: + return false + } +} + +// Defines values for DeviceRegistrationOs. +const ( + Android DeviceRegistrationOs = "android" + Ios DeviceRegistrationOs = "ios" + Web DeviceRegistrationOs = "web" +) + +// Valid indicates whether the value is a known member of the DeviceRegistrationOs enum. +func (e DeviceRegistrationOs) Valid() bool { + switch e { + case Android: + return true + case Ios: + return true + case Web: + return true + default: + return false + } +} + +// Defines values for GetOrganizationInboxParamsStatus. +const ( + GetOrganizationInboxParamsStatusArchived GetOrganizationInboxParamsStatus = "archived" + GetOrganizationInboxParamsStatusRead GetOrganizationInboxParamsStatus = "read" + GetOrganizationInboxParamsStatusUnread GetOrganizationInboxParamsStatus = "unread" +) + +// Valid indicates whether the value is a known member of the GetOrganizationInboxParamsStatus enum. +func (e GetOrganizationInboxParamsStatus) Valid() bool { + switch e { + case GetOrganizationInboxParamsStatusArchived: + return true + case GetOrganizationInboxParamsStatusRead: + return true + case GetOrganizationInboxParamsStatusUnread: + return true + default: + return false + } +} + +// Defines values for GetUserInboxParamsStatus. +const ( + GetUserInboxParamsStatusArchived GetUserInboxParamsStatus = "archived" + GetUserInboxParamsStatusRead GetUserInboxParamsStatus = "read" + GetUserInboxParamsStatusUnread GetUserInboxParamsStatus = "unread" +) + +// Valid indicates whether the value is a known member of the GetUserInboxParamsStatus enum. +func (e GetUserInboxParamsStatus) Valid() bool { + switch e { + case GetUserInboxParamsStatusArchived: + return true + case GetUserInboxParamsStatusRead: + return true + case GetUserInboxParamsStatusUnread: + return true + default: + return false + } +} + +// Channel defines model for Channel. +type Channel string + +// CreateSession Request to mint a session token for an end user. +type CreateSession struct { + // UserID The end user's external identifier (the session subject). + UserID string `json:"user_id"` +} + +// DeleteOrganizationRequest defines model for DeleteOrganizationRequest. +type DeleteOrganizationRequest struct { + // Identifier One or more external identifiers to identify the organization + Identifier OrganizationIdentifier `json:"identifier"` +} + +// DeleteOrganizationScheduledRequest defines model for DeleteOrganizationScheduledRequest. +type DeleteOrganizationScheduledRequest struct { + // Identifier One or more external identifiers to identify the organization + Identifier OrganizationIdentifier `json:"identifier"` + + // Name The name of the scheduled resource to delete + Name string `json:"name"` +} + +// DeleteUserRequest defines model for DeleteUserRequest. +type DeleteUserRequest struct { + // Identifier One or more external identifiers to identify the user + Identifier UserIdentifier `json:"identifier"` +} + +// DeleteUserScheduledRequest defines model for DeleteUserScheduledRequest. +type DeleteUserScheduledRequest struct { + // Identifier One or more external identifiers to identify the user + Identifier *UserIdentifier `json:"identifier,omitempty"` + + // Name The name of the scheduled resource to delete + Name string `json:"name"` +} + +// DeviceRegistration defines model for DeviceRegistration. +type DeviceRegistration struct { + AppVersion *string `json:"app_version,omitempty"` + Config struct { + // Endpoint Web Push subscription endpoint URL + Endpoint *string `json:"endpoint,omitempty"` + ExpirationTime *time.Time `json:"expiration_time,omitempty"` + Keys *struct { + Auth string `json:"auth"` + P256Dh string `json:"p256dh"` + } `json:"keys,omitempty"` + + // Token Device token for FCM or APNs + Token *string `json:"token,omitempty"` + } `json:"config"` + Data *json.RawMessage `json:"data,omitempty"` + DeviceID string `json:"device_id"` + + // Identifier One or more external identifiers to identify the user + Identifier UserIdentifier `json:"identifier"` + Model *string `json:"model,omitempty"` + Os *DeviceRegistrationOs `json:"os,omitempty"` + OsVersion *string `json:"os_version,omitempty"` +} + +// DeviceRegistrationOs defines model for DeviceRegistration.Os. +type DeviceRegistrationOs string + +// Event defines model for Event. +type Event struct { + // Data Event-specific data + Data map[string]any `json:"data"` + + // Identifier One or more external identifiers to identify the user + Identifier *UserIdentifier `json:"identifier,omitempty"` + + // Match JSONB containment filter to match users by their data attributes. Mutually exclusive with identifier. When set, the event is delivered to every user whose data column contains the given key/value pairs. + Match *map[string]any `json:"match,omitempty"` + + // Name The name of the event + Name string `json:"name"` +} + +// ExternalID An external identifier with source and optional metadata +type ExternalID struct { + // ExternalID The external identifier value + ExternalID string `json:"external_id"` + + // Metadata Optional metadata associated with this identifier + Metadata *map[string]any `json:"metadata,omitempty"` + + // Source Source of the identifier (e.g. "default", "anonymous", or a custom source). Defaults to "default" if not provided. + Source *string `json:"source,omitempty"` +} + +// ExternalIDResponse An external identifier as returned in responses, including database ID and timestamps +type ExternalIDResponse struct { + CreatedAt time.Time `json:"created_at"` + ExternalID string `json:"external_id"` + ID openapi_types.UUID `json:"id"` + Metadata *map[string]any `json:"metadata,omitempty"` + Source string `json:"source"` + UpdatedAt time.Time `json:"updated_at"` +} + +// IdentifyRequest defines model for IdentifyRequest. +type IdentifyRequest struct { + // Data User-specific attributes + Data *map[string]any `json:"data,omitempty"` + Email *string `json:"email,omitempty"` + + // Identifier One or more external identifiers to identify the user + Identifier UserIdentifier `json:"identifier"` + Locale *string `json:"locale,omitempty"` + + // Phone E.164 formatted phone number + Phone *string `json:"phone,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +// InboxCount defines model for InboxCount. +type InboxCount struct { + Total int `json:"total"` + Unread int `json:"unread"` +} + +// InboxMessage defines model for InboxMessage. +type InboxMessage struct { + ArchivedAt *time.Time `json:"archived_at,omitempty"` + BroadcastID *openapi_types.UUID `json:"broadcast_id,omitempty"` + CampaignID *openapi_types.UUID `json:"campaign_id,omitempty"` + Channel Channel `json:"channel"` + Content json.RawMessage `json:"content"` + CreatedAt time.Time `json:"created_at"` + Data json.RawMessage `json:"data"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + + // ExternalID External identifier for the message, if one was provided at creation time. + ExternalID *string `json:"external_id,omitempty"` + ID openapi_types.UUID `json:"id"` + OrganizationID *openapi_types.UUID `json:"organization_id,omitempty"` + Priority int16 `json:"priority"` + ProjectID openapi_types.UUID `json:"project_id"` + ReadAt *time.Time `json:"read_at,omitempty"` + ScheduledAt time.Time `json:"scheduled_at"` + SenderIdentityID *openapi_types.UUID `json:"sender_identity_id,omitempty"` + SentAt *time.Time `json:"sent_at,omitempty"` + Source *string `json:"source,omitempty"` + Tags []string `json:"tags"` + UpdatedAt time.Time `json:"updated_at"` + UserID *openapi_types.UUID `json:"user_id,omitempty"` +} + +// InboxMessageCreate defines model for InboxMessageCreate. +type InboxMessageCreate struct { + BroadcastID *openapi_types.UUID `json:"broadcast_id,omitempty"` + CampaignID *openapi_types.UUID `json:"campaign_id,omitempty"` + Channel Channel `json:"channel"` + + // Content Channel-specific payload content. + Content *json.RawMessage `json:"content,omitempty"` + Data *map[string]any `json:"data,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + + // Identifier An external identifier with source and optional metadata + Identifier ExternalID `json:"identifier"` + Priority *int16 `json:"priority,omitempty"` + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` + + // SenderIdentityID Required for email and sms messages. Push uses project push provider settings. + SenderIdentityID *openapi_types.UUID `json:"sender_identity_id,omitempty"` + Source *string `json:"source,omitempty"` + Tags *[]string `json:"tags,omitempty"` + + // Target One or more external identifiers to identify the user + Target UserIdentifier `json:"target"` +} + +// InboxMessageList defines model for InboxMessageList. +type InboxMessageList struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Results []InboxMessage `json:"results"` + Total int `json:"total"` +} + +// Organization defines model for Organization. +type Organization struct { + CreatedAt time.Time `json:"created_at"` + Data map[string]any `json:"data"` + ID openapi_types.UUID `json:"id"` + + // Identifier External identifiers associated with this organization + Identifier []ExternalIDResponse `json:"identifier"` + Name *string `json:"name,omitempty"` + ProjectID openapi_types.UUID `json:"project_id"` + UpdatedAt time.Time `json:"updated_at"` + Version int32 `json:"version"` +} + +// OrganizationEvent defines model for OrganizationEvent. +type OrganizationEvent struct { + // Data Event-specific data + Data *map[string]any `json:"data,omitempty"` + + // Identifier One or more external identifiers to identify the organization + Identifier *OrganizationIdentifier `json:"identifier,omitempty"` + + // Match JSONB containment filter to match organizations by their data attributes. Mutually exclusive with identifier. When set, the event is delivered to every organization whose data column contains the given key/value pairs. + Match *map[string]any `json:"match,omitempty"` + + // Name The name of the event + Name string `json:"name"` +} + +// OrganizationIdentifier One or more external identifiers to identify the organization +type OrganizationIdentifier = []ExternalID + +// OrganizationInboxMessageCreate defines model for OrganizationInboxMessageCreate. +type OrganizationInboxMessageCreate struct { + BroadcastID *openapi_types.UUID `json:"broadcast_id,omitempty"` + CampaignID *openapi_types.UUID `json:"campaign_id,omitempty"` + Channel Channel `json:"channel"` + + // Content Channel-specific payload content. + Content *json.RawMessage `json:"content,omitempty"` + Data *map[string]any `json:"data,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + + // Identifier An external identifier with source and optional metadata + Identifier ExternalID `json:"identifier"` + Priority *int16 `json:"priority,omitempty"` + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` + + // SenderIdentityID Required for email and sms messages. Push uses project push provider settings. + SenderIdentityID *openapi_types.UUID `json:"sender_identity_id,omitempty"` + Source *string `json:"source,omitempty"` + Tags *[]string `json:"tags,omitempty"` + + // Target One or more external identifiers to identify the organization + Target OrganizationIdentifier `json:"target"` +} + +// OrganizationInboxMessageEvents defines model for OrganizationInboxMessageEvents. +type OrganizationInboxMessageEvents = []OrganizationInboxMessageRef + +// OrganizationInboxMessageRef defines model for OrganizationInboxMessageRef. +type OrganizationInboxMessageRef struct { + MessageID openapi_types.UUID `json:"message_id"` + + // Target One or more external identifiers to identify the organization + Target OrganizationIdentifier `json:"target"` +} + +// OrganizationRequest defines model for OrganizationRequest. +type OrganizationRequest struct { + Data *map[string]any `json:"data,omitempty"` + + // Identifier One or more external identifiers to identify the organization + Identifier OrganizationIdentifier `json:"identifier"` + Name *string `json:"name,omitempty"` +} + +// OrganizationUserRequest defines model for OrganizationUserRequest. +type OrganizationUserRequest struct { + // Data Organization-specific data for this user + Data *map[string]any `json:"data,omitempty"` + Organization struct { + // Identifier One or more external identifiers to identify the organization + Identifier OrganizationIdentifier `json:"identifier"` + } `json:"organization"` + User struct { + // Identifier One or more external identifiers to identify the user + Identifier UserIdentifier `json:"identifier"` + } `json:"user"` +} + +// PostEventsRequest defines model for PostEventsRequest. +type PostEventsRequest = []Event + +// PostOrganizationEventsRequest defines model for PostOrganizationEventsRequest. +type PostOrganizationEventsRequest = []OrganizationEvent + +// PostOrganizationInboxMessagesRequest defines model for PostOrganizationInboxMessagesRequest. +type PostOrganizationInboxMessagesRequest = []OrganizationInboxMessageCreate + +// PostUserInboxMessagesRequest defines model for PostUserInboxMessagesRequest. +type PostUserInboxMessagesRequest = []InboxMessageCreate + +// Problem defines model for Problem. +type Problem struct { + // Detail A human readable explanation specific to this occurrence of the problem that is helpful to locate the problem and give advice on how to proceed. Written in English and readable for engineers, usually not suited for non technical stakeholders and not localized. + Detail string `json:"detail"` + + // Title A short summary of the problem type. Written in English and readable for engineers, usually not suited for non technical stakeholders and not localized. + Title string `json:"title"` +} + +// RemoveOrganizationUserRequest defines model for RemoveOrganizationUserRequest. +type RemoveOrganizationUserRequest struct { + Organization struct { + // Identifier One or more external identifiers to identify the organization + Identifier OrganizationIdentifier `json:"identifier"` + } `json:"organization"` + User struct { + // Identifier One or more external identifiers to identify the user + Identifier UserIdentifier `json:"identifier"` + } `json:"user"` +} + +// ScheduledAccepted defines model for ScheduledAccepted. +type ScheduledAccepted struct { + // Data Scheduled resource data + Data *map[string]any `json:"data,omitempty"` + + // ID The unique identifier for the scheduled instance + ID openapi_types.UUID `json:"id"` + + // Name The name of the scheduled resource + Name string `json:"name"` + + // ScheduledAt The time at which the scheduled resource is set to trigger + ScheduledAt time.Time `json:"scheduled_at"` +} + +// SessionToken defines model for SessionToken. +type SessionToken struct { + ExpiresAt time.Time `json:"expires_at"` + + // Token The signed session token (a bearer token for the Client API). + Token string `json:"token"` +} + +// UpsertOrganizationScheduledRequest defines model for UpsertOrganizationScheduledRequest. +type UpsertOrganizationScheduledRequest struct { + // Data Scheduled resource data + Data *map[string]any `json:"data,omitempty"` + + // Identifier One or more external identifiers to identify the organization + Identifier OrganizationIdentifier `json:"identifier"` + + // Interval Interval for recurring schedules. When set, the schedule type is automatically set to recurring. + Interval *string `json:"interval,omitempty"` + + // Name The name of the scheduled resource + Name string `json:"name"` + + // ScheduledAt The time at which the scheduled resource is set to trigger. Required for single schedules. + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` + + // StartAt Start time for recurring schedules. If omitted for recurring schedules, defaults to now. + StartAt *time.Time `json:"start_at,omitempty"` +} + +// UpsertUserScheduledRequest defines model for UpsertUserScheduledRequest. +type UpsertUserScheduledRequest struct { + // Data Scheduled resource data + Data *map[string]any `json:"data,omitempty"` + + // Identifier One or more external identifiers to identify the user + Identifier *UserIdentifier `json:"identifier,omitempty"` + + // Interval Interval for recurring schedules. When set, the schedule type is automatically set to recurring. + Interval *string `json:"interval,omitempty"` + + // Name The name of the scheduled resource + Name string `json:"name"` + + // ScheduledAt The time at which the scheduled resource is set to trigger. Required for single schedules. + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` + + // StartAt Start time for recurring schedules. If omitted for recurring schedules, defaults to now. + StartAt *time.Time `json:"start_at,omitempty"` +} + +// User defines model for User. +type User struct { + CreatedAt time.Time `json:"created_at"` + Data map[string]any `json:"data"` + Email *string `json:"email,omitempty"` + HasPushDevice bool `json:"has_push_device"` + ID openapi_types.UUID `json:"id"` + + // Identifier External identifiers associated with this user + Identifier []ExternalIDResponse `json:"identifier"` + Locale *string `json:"locale,omitempty"` + + // Phone E.164 formatted phone number + Phone *string `json:"phone,omitempty"` + ProjectID openapi_types.UUID `json:"project_id"` + Timezone *string `json:"timezone,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + Version int32 `json:"version"` +} + +// UserIdentifier One or more external identifiers to identify the user +type UserIdentifier = []ExternalID + +// UserInboxMessageEvents defines model for UserInboxMessageEvents. +type UserInboxMessageEvents = []UserInboxMessageRef + +// UserInboxMessageRef defines model for UserInboxMessageRef. +type UserInboxMessageRef struct { + MessageID openapi_types.UUID `json:"message_id"` + + // Target One or more external identifiers to identify the user + Target UserIdentifier `json:"target"` +} + +// VapidPublicKey defines model for VapidPublicKey. +type VapidPublicKey struct { + // PublicKey The VAPID public key + PublicKey string `json:"public_key"` +} + +// Limit defines model for Limit. +type Limit = PaginationLimit + +// Offset defines model for Offset. +type Offset = PaginationOffset + +// ProjectID defines model for ProjectID. +type ProjectID = openapi_types.UUID + +// Error defines model for Error. +type Error = Problem + +// hTTPBearerAuthContextKey is the context key for HttpBearerAuth security scheme +type hTTPBearerAuthContextKey string + +// GetOrganizationInboxParams defines parameters for GetOrganizationInbox. +type GetOrganizationInboxParams struct { + Source string `form:"source" json:"source"` + ExternalID string `form:"external_id" json:"external_id"` + Status *GetOrganizationInboxParamsStatus `form:"status,omitempty" json:"status,omitempty"` + + // Tags Comma-separated tag filter. All listed tags must be present. + Tags *string `form:"tags,omitempty" json:"tags,omitempty"` + MessageSource *string `form:"message_source,omitempty" json:"message_source,omitempty"` + Priority *int `form:"priority,omitempty" json:"priority,omitempty"` + Channel Channel `form:"channel" json:"channel"` + + // Limit Maximum number of items to return + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of items to skip + Offset *Offset `form:"offset,omitempty" json:"offset,omitempty"` +} + +// GetOrganizationInboxParamsStatus defines parameters for GetOrganizationInbox. +type GetOrganizationInboxParamsStatus string + +// GetOrganizationInboxCountParams defines parameters for GetOrganizationInboxCount. +type GetOrganizationInboxCountParams struct { + Source string `form:"source" json:"source"` + ExternalID string `form:"external_id" json:"external_id"` + Channel Channel `form:"channel" json:"channel"` +} + +// GetUserInboxParams defines parameters for GetUserInbox. +type GetUserInboxParams struct { + Source string `form:"source" json:"source"` + ExternalID string `form:"external_id" json:"external_id"` + Status *GetUserInboxParamsStatus `form:"status,omitempty" json:"status,omitempty"` + + // Tags Comma-separated tag filter. All listed tags must be present. + Tags *string `form:"tags,omitempty" json:"tags,omitempty"` + MessageSource *string `form:"message_source,omitempty" json:"message_source,omitempty"` + Priority *int `form:"priority,omitempty" json:"priority,omitempty"` + Channel Channel `form:"channel" json:"channel"` + + // Limit Maximum number of items to return + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of items to skip + Offset *Offset `form:"offset,omitempty" json:"offset,omitempty"` +} + +// GetUserInboxParamsStatus defines parameters for GetUserInbox. +type GetUserInboxParamsStatus string + +// GetUserInboxCountParams defines parameters for GetUserInboxCount. +type GetUserInboxCountParams struct { + Source string `form:"source" json:"source"` + ExternalID string `form:"external_id" json:"external_id"` + Channel Channel `form:"channel" json:"channel"` +} + +// UpdatePreferencesFormdataBody defines parameters for UpdatePreferences. +type UpdatePreferencesFormdataBody struct { + // SubscriptionIds Array of subscription IDs to keep subscribed + SubscriptionIds *[]openapi_types.UUID `form:"subscriptionIds,omitempty" json:"subscriptionIds,omitempty"` +} + +// EmailUnsubscribeParams defines parameters for EmailUnsubscribe. +type EmailUnsubscribeParams struct { + // Link Encoded unsubscribe link with user and campaign data + Link string `form:"link" json:"link"` +} + +// CreateSessionJSONRequestBody defines body for CreateSession for application/json ContentType. +type CreateSessionJSONRequestBody = CreateSession + +// DeleteOrganizationClientJSONRequestBody defines body for DeleteOrganizationClient for application/json ContentType. +type DeleteOrganizationClientJSONRequestBody = DeleteOrganizationRequest + +// UpsertOrganizationClientJSONRequestBody defines body for UpsertOrganizationClient for application/json ContentType. +type UpsertOrganizationClientJSONRequestBody = OrganizationRequest + +// PostOrganizationEventsClientJSONRequestBody defines body for PostOrganizationEventsClient for application/json ContentType. +type PostOrganizationEventsClientJSONRequestBody = PostOrganizationEventsRequest + +// PostOrganizationInboxMessagesJSONRequestBody defines body for PostOrganizationInboxMessages for application/json ContentType. +type PostOrganizationInboxMessagesJSONRequestBody = PostOrganizationInboxMessagesRequest + +// PostOrganizationInboxArchivedJSONRequestBody defines body for PostOrganizationInboxArchived for application/json ContentType. +type PostOrganizationInboxArchivedJSONRequestBody = OrganizationInboxMessageEvents + +// PostOrganizationInboxReadJSONRequestBody defines body for PostOrganizationInboxRead for application/json ContentType. +type PostOrganizationInboxReadJSONRequestBody = OrganizationInboxMessageEvents + +// DeleteOrganizationScheduledClientJSONRequestBody defines body for DeleteOrganizationScheduledClient for application/json ContentType. +type DeleteOrganizationScheduledClientJSONRequestBody = DeleteOrganizationScheduledRequest + +// UpsertOrganizationScheduledClientJSONRequestBody defines body for UpsertOrganizationScheduledClient for application/json ContentType. +type UpsertOrganizationScheduledClientJSONRequestBody = UpsertOrganizationScheduledRequest + +// RemoveOrganizationUserClientJSONRequestBody defines body for RemoveOrganizationUserClient for application/json ContentType. +type RemoveOrganizationUserClientJSONRequestBody = RemoveOrganizationUserRequest + +// AddOrganizationUserClientJSONRequestBody defines body for AddOrganizationUserClient for application/json ContentType. +type AddOrganizationUserClientJSONRequestBody = OrganizationUserRequest + +// DeleteUserClientJSONRequestBody defines body for DeleteUserClient for application/json ContentType. +type DeleteUserClientJSONRequestBody = DeleteUserRequest + +// UpsertUserClientJSONRequestBody defines body for UpsertUserClient for application/json ContentType. +type UpsertUserClientJSONRequestBody = IdentifyRequest + +// RegisterDeviceJSONRequestBody defines body for RegisterDevice for application/json ContentType. +type RegisterDeviceJSONRequestBody = DeviceRegistration + +// PostUserEventsJSONRequestBody defines body for PostUserEvents for application/json ContentType. +type PostUserEventsJSONRequestBody = PostEventsRequest + +// PostUserInboxMessagesJSONRequestBody defines body for PostUserInboxMessages for application/json ContentType. +type PostUserInboxMessagesJSONRequestBody = PostUserInboxMessagesRequest + +// PostUserInboxArchivedJSONRequestBody defines body for PostUserInboxArchived for application/json ContentType. +type PostUserInboxArchivedJSONRequestBody = UserInboxMessageEvents + +// PostUserInboxReadJSONRequestBody defines body for PostUserInboxRead for application/json ContentType. +type PostUserInboxReadJSONRequestBody = UserInboxMessageEvents + +// DeleteUserScheduledClientJSONRequestBody defines body for DeleteUserScheduledClient for application/json ContentType. +type DeleteUserScheduledClientJSONRequestBody = DeleteUserScheduledRequest + +// UpsertUserScheduledClientJSONRequestBody defines body for UpsertUserScheduledClient for application/json ContentType. +type UpsertUserScheduledClientJSONRequestBody = UpsertUserScheduledRequest + +// UpdatePreferencesFormdataRequestBody defines body for UpdatePreferences for application/x-www-form-urlencoded ContentType. +type UpdatePreferencesFormdataRequestBody UpdatePreferencesFormdataBody diff --git a/gen/config.yaml b/gen/config.yaml new file mode 100644 index 0000000..0dceaab --- /dev/null +++ b/gen/config.yaml @@ -0,0 +1,11 @@ +# oapi-codegen configuration for the Lunogram Go SDK low-level layer. +# +# Generates Go types from the vendored OpenAPI spec (../spec/client.yaml). The +# ToCamelCaseWithInitialisms normalizer yields idiomatic Go names (ID, +# ExternalID, ProjectID) matching the SDK's public surface. +package: gen +output: client.gen.go +generate: + models: true +output-options: + name-normalizer: "ToCamelCaseWithInitialisms" diff --git a/gen/gen.go b/gen/gen.go new file mode 100644 index 0000000..8f03e59 --- /dev/null +++ b/gen/gen.go @@ -0,0 +1,12 @@ +// Package gen contains the low-level, spec-driven layer of the Lunogram Go SDK. +// +// The types in client.gen.go are generated from the vendored OpenAPI spec +// (../spec/client.yaml) with oapi-codegen. Do not edit them by hand; run +// `go generate ./...` (or `make generate`) to regenerate after the spec +// changes. See spec/SOURCE.md for the spec's source and pinned ref. +// +// The hand-written facade in the root package builds on these types; see the +// package documentation there for the public API. +package gen + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config config.yaml ../spec/client.yaml diff --git a/gen/types.go b/gen/types.go new file mode 100644 index 0000000..98fb475 --- /dev/null +++ b/gen/types.go @@ -0,0 +1,14 @@ +package gen + +// PaginationLimit and PaginationOffset back the `x-go-type` references in the +// vendored spec's pagination query parameters (the Limit/Offset components). +// +// The platform defines these in a hand-written companion file alongside its own +// generated code; the SDK mirrors that pattern here so the generated package is +// self-contained. They are only referenced by the inbox/list parameters, which +// the SDK facade does not currently expose. This file is hand-written and is +// not overwritten by `go generate`. +type ( + PaginationLimit int + PaginationOffset int +) diff --git a/go.mod b/go.mod index ae4693e..aa360e4 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,37 @@ module github.com/lunogram/go-sdk -go 1.23.0 +go 1.24.3 require ( github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 ) -tool github.com/golangci/golangci-lint/cmd/golangci-lint +require ( + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/getkin/kin-openapi v0.135.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.9 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.19.2 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/tools/go/expect v0.1.1-deprecated // indirect + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect +) + +tool ( + github.com/golangci/golangci-lint/cmd/golangci-lint + github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen +) require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect @@ -46,7 +70,7 @@ require ( github.com/ckaznocha/intrange v0.3.0 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.5 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/color v1.18.0 // indirect @@ -121,10 +145,11 @@ require ( github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.19.1 // indirect + github.com/oapi-codegen/runtime v1.1.1 github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polyfloyd/go-errorlint v1.7.1 // indirect github.com/prometheus/client_golang v1.12.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect @@ -141,7 +166,7 @@ require ( github.com/ryancurrah/gomodguard v1.3.5 // indirect github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sashamelentyev/interfacebloat v1.1.0 // indirect github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect github.com/securego/gosec/v2 v2.22.2 // indirect @@ -182,11 +207,11 @@ require ( go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.22.0 // indirect - golang.org/x/tools v0.31.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 5578415..2a9aa63 100644 --- a/go.sum +++ b/go.sum @@ -128,12 +128,16 @@ github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPw github.com/daixiang0/gci v0.13.5 h1:kThgmH1yBmZSBCh1EJVxQ7JsHpm5Oms0AMed/0LaH4c= github.com/daixiang0/gci v0.13.5/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -148,10 +152,14 @@ github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6 github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= +github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= +github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ= github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w= @@ -167,11 +175,21 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= @@ -271,6 +289,7 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= @@ -308,7 +327,9 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk= @@ -317,6 +338,8 @@ github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjz github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc= github.com/jjti/go-spancheck v0.6.4/go.mod h1:yAEYdKJ2lRkDA8g7X+oKUHXOWVAXSBJRv04OhF+QUjk= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -367,6 +390,8 @@ github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= @@ -395,6 +420,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -407,10 +434,32 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4= github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= +github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= @@ -424,12 +473,15 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v1.7.1 h1:RyLVXIbosq1gBdk/pChWA8zWYLsq9UEw7a1L5TVMCnA= github.com/polyfloyd/go-errorlint v1.7.1/go.mod h1:aXjNb1x2TNhoLsk26iv1yl7a+zTnXPhwEMtEXukiLR8= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= @@ -481,14 +533,16 @@ github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9f github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxXUzwsMDBkR21cyQ= github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8= github.com/securego/gosec/v2 v2.22.2 h1:IXbuI7cJninj0nRpZSLCUlotsj8jGusohfONMrHoF6g= github.com/securego/gosec/v2 v2.22.2/go.mod h1:UEBGA+dSKb+VqM6TdehR7lnQtIIMorYJ4/9CW1KVQBE= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -504,6 +558,10 @@ github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM= github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c= github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= +github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= @@ -555,6 +613,8 @@ github.com/tomarrell/wrapcheck/v2 v2.10.0 h1:SzRCryzy4IrAH7bVGG4cK40tNUhmVmMDuJu github.com/tomarrell/wrapcheck/v2 v2.10.0/go.mod h1:g9vNIyhb5/9TQgumxQyOEqDHsmGYcGsVMOx/xGkqdMo= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= @@ -563,6 +623,10 @@ github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYR github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U= github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/xen0n/gosmopolitan v1.2.2 h1:/p2KTnMzwRexIW8GlKawsTWOxn7UHA+jCMF/V8HHtvU= github.com/xen0n/gosmopolitan v1.2.2/go.mod h1:7XX7Mj61uLYrj0qmeN0zi7XDon9JRAEhYQqAPLVNTeg= github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= @@ -601,6 +665,8 @@ go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -655,10 +721,11 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -681,6 +748,7 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= @@ -688,8 +756,10 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -697,8 +767,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -720,10 +790,11 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -734,7 +805,10 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -755,6 +829,7 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -763,6 +838,7 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -774,8 +850,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -796,8 +872,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -846,6 +922,7 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= @@ -857,8 +934,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -947,8 +1028,11 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -956,6 +1040,7 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/spec/SOURCE.md b/spec/SOURCE.md new file mode 100644 index 0000000..3031ac6 --- /dev/null +++ b/spec/SOURCE.md @@ -0,0 +1,38 @@ +# Spec source + +`spec/client.yaml` is **vendored** (committed) from the Lunogram platform repo. +The SDK's low-level layer (`gen/client.gen.go`) is generated from it via +`make generate` (`go generate ./...`). Do not hand-edit either file. + +| Field | Value | +| ----------- | --------------------------------------------------------- | +| Source repo | https://github.com/lunogram/platform | +| Spec path | `internal/http/controllers/v1/client/oapi/resources.yml` | +| Pinned ref | `a12f901dc98e7ced44efbad27a388e8bf5ee0f3a` | + +Raw URL used by `spec-sync.yml` to re-fetch: + +``` +https://raw.githubusercontent.com/lunogram/platform/a12f901dc98e7ced44efbad27a388e8bf5ee0f3a/internal/http/controllers/v1/client/oapi/resources.yml +``` + +## About the pin + +The ref above is the head of platform **PR #262**'s branch — a branch/commit +pin used during development. Pinning to a commit means **no platform release is +required** to consume the spec: any ref (branch, tag, or SHA) is fetchable from +the public repo via the raw URL. + +**TODO:** once the platform cuts a release that ships the client spec (e.g. +`resources.yml` from a `v*.*.*` tag), flip the pinned ref to that tag for a +stable, reproducible source. + +## How to bump the pin + +1. Update the **Pinned ref** value above (and the raw URL). +2. Run `make generate` to regenerate `gen/client.gen.go`. +3. Commit `spec/` and `gen/` together. + +The `spec-sync.yml` workflow automates re-fetching at the current pin and opens +a PR when the spec or generated output drifts; bumping the pin itself is a +manual edit to this file. diff --git a/spec/client.yaml b/spec/client.yaml new file mode 100644 index 0000000..5bdf2ca --- /dev/null +++ b/spec/client.yaml @@ -0,0 +1,1779 @@ +openapi: 3.0.3 +info: + title: Lunogram Public API + description: API for ingesting data such as user profiles and events. + version: 1.0.0 + +paths: + /api/client/projects/{projectID}/users: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Upsert user + description: Used by client libraries to create or update a user profile. The project is taken from the projectID path parameter. + operationId: upsertUserClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IdentifyRequest" + responses: + "200": + description: User upserted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/User" + default: + $ref: "#/components/responses/Error" + delete: + summary: Delete user + description: | + Used by client libraries to delete a user by external_id or anonymous_id. + The project is taken from the projectID path parameter. + operationId: deleteUserClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteUserRequest" + responses: + "204": + description: User deleted successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/events: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Post user events + description: | + Used by client libraries to trigger events for a user that can be used to execute a step in a journey or update a virtual list. + Multiple events can be sent in a single request and are processed asynchronously. + Each event is handled independently: if one event fails to be accepted or processed, other events in the same request may still be published successfully. + Clients requiring deterministic retry or recovery behavior must submit events individually. + The project is taken from the projectID path parameter. + operationId: postUserEvents + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostEventsRequest" + responses: + "202": + description: Events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/inbox: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Create user inbox messages + description: Creates one or more inbox messages for users identified by external identifiers. Messages are processed asynchronously. + operationId: postUserInboxMessages + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostUserInboxMessagesRequest" + responses: + "202": + description: Inbox messages accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + get: + summary: Query user inbox messages + description: Returns visible, non-expired inbox messages for a user identified by source and external_id. + operationId: getUserInbox + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: source + in: query + required: true + schema: + type: string + - name: external_id + in: query + required: true + schema: + type: string + - name: status + in: query + schema: + type: string + enum: [unread, read, archived] + - name: tags + in: query + schema: + type: string + description: Comma-separated tag filter. All listed tags must be present. + - name: message_source + in: query + schema: + type: string + - name: priority + in: query + schema: + type: integer + - name: channel + in: query + required: true + schema: + $ref: "#/components/schemas/Channel" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + responses: + "200": + description: User inbox messages retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/InboxMessageList" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/inbox/count: + parameters: + - $ref: "#/components/parameters/ProjectID" + get: + summary: Count user inbox messages + description: Returns unread and total visible inbox message counts for a user. + operationId: getUserInboxCount + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: source + in: query + required: true + schema: + type: string + - name: external_id + in: query + required: true + schema: + type: string + - name: channel + in: query + required: true + schema: + $ref: "#/components/schemas/Channel" + responses: + "200": + description: User inbox count retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/InboxCount" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/inbox/read: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mark user inbox messages read + description: Marks one or more user inbox messages as read. Processed asynchronously. + operationId: postUserInboxRead + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserInboxMessageEvents" + responses: + "202": + description: Inbox read events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/inbox/archived: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mark user inbox messages archived + description: Marks one or more user inbox messages as archived. Processed asynchronously. + operationId: postUserInboxArchived + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserInboxMessageEvents" + responses: + "202": + description: Inbox archived events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Upsert organization + description: | + Used by client libraries to create or update an organization by external_id. + The project is taken from the projectID path parameter. + operationId: upsertOrganizationClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationRequest" + responses: + "200": + description: Organization upserted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + default: + $ref: "#/components/responses/Error" + delete: + summary: Delete organization + description: | + Used by client libraries to delete an organization by external_id. + This will also remove all organization memberships. + The project is taken from the projectID path parameter. + operationId: deleteOrganizationClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteOrganizationRequest" + responses: + "204": + description: Organization deleted successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/users: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Add user to organization + description: | + Used by client libraries to add a user to an organization with optional org-specific data. + Both organization and user are identified by their external_id. + The project is taken from the projectID path parameter. + operationId: addOrganizationUserClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationUserRequest" + responses: + "200": + description: User added to organization successfully + default: + $ref: "#/components/responses/Error" + + delete: + summary: Remove user from organization + description: | + Used by client libraries to remove a user from an organization. + Both organization and user are identified by their external_id. + The project is taken from the projectID path parameter. + operationId: removeOrganizationUserClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RemoveOrganizationUserRequest" + responses: + "204": + description: User removed from organization successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/scheduled: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Upsert user scheduled + description: | + Used by client libraries to create or update a scheduled resource for a user. + The user is identified by external_id or anonymous_id. + This triggers journey entrance recalculation for any journeys that depend on the scheduled resource. + The project is taken from the projectID path parameter. + operationId: upsertUserScheduledClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertUserScheduledRequest" + responses: + "202": + description: Scheduled resource accepted for asynchronous processing + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledAccepted" + default: + $ref: "#/components/responses/Error" + delete: + summary: Delete user scheduled + description: | + Used by client libraries to delete all scheduled instances for a user with a given scheduled resource name. + The user is identified by external_id or anonymous_id. + This cancels any pending journey entrance states that depend on the scheduled resource. + The project is taken from the projectID path parameter. + operationId: deleteUserScheduledClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteUserScheduledRequest" + responses: + "200": + description: User scheduled deleted successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/scheduled: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Upsert organization scheduled + description: | + Used by client libraries to create or update a scheduled resource for an organization. + The organization is identified by external_id. + This triggers journey entrance recalculation for any journeys that depend on the scheduled resource. + The project is taken from the projectID path parameter. + operationId: upsertOrganizationScheduledClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertOrganizationScheduledRequest" + responses: + "202": + description: Scheduled resource accepted for asynchronous processing + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledAccepted" + default: + $ref: "#/components/responses/Error" + delete: + summary: Delete organization scheduled + description: | + Used by client libraries to delete all scheduled instances for an organization with a given scheduled resource name. + The organization is identified by external_id. + This cancels any pending journey entrance states that depend on the scheduled resource. + The project is taken from the projectID path parameter. + operationId: deleteOrganizationScheduledClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteOrganizationScheduledRequest" + responses: + "200": + description: Organization scheduled deleted successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/events: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Post organization events + description: | + Used by client libraries to trigger events on an organization. + The organization is identified by external_id. + Events are processed asynchronously and can trigger journeys for all users in the organization. + The project is taken from the projectID path parameter. + operationId: postOrganizationEventsClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostOrganizationEventsRequest" + responses: + "202": + description: Events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/inbox: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Create organization inbox messages + description: Creates one or more inbox messages for organizations identified by external identifiers. Messages are processed asynchronously. + operationId: postOrganizationInboxMessages + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostOrganizationInboxMessagesRequest" + responses: + "202": + description: Inbox messages accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + get: + summary: Query organization inbox messages + description: Returns visible, non-expired inbox messages for an organization identified by source and external_id. + operationId: getOrganizationInbox + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: source + in: query + required: true + schema: + type: string + - name: external_id + in: query + required: true + schema: + type: string + - name: status + in: query + schema: + type: string + enum: [unread, read, archived] + - name: tags + in: query + schema: + type: string + description: Comma-separated tag filter. All listed tags must be present. + - name: message_source + in: query + schema: + type: string + - name: priority + in: query + schema: + type: integer + - name: channel + in: query + required: true + schema: + $ref: "#/components/schemas/Channel" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + responses: + "200": + description: Organization inbox messages retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/InboxMessageList" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/inbox/count: + parameters: + - $ref: "#/components/parameters/ProjectID" + get: + summary: Count organization inbox messages + description: Returns unread and total visible inbox message counts for an organization. + operationId: getOrganizationInboxCount + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: source + in: query + required: true + schema: + type: string + - name: external_id + in: query + required: true + schema: + type: string + - name: channel + in: query + required: true + schema: + $ref: "#/components/schemas/Channel" + responses: + "200": + description: Organization inbox count retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/InboxCount" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/inbox/read: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mark organization inbox messages read + description: Marks one or more organization inbox messages as read. Processed asynchronously. + operationId: postOrganizationInboxRead + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationInboxMessageEvents" + responses: + "202": + description: Inbox read events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/inbox/archived: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mark organization inbox messages archived + description: Marks one or more organization inbox messages as archived. Processed asynchronously. + operationId: postOrganizationInboxArchived + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationInboxMessageEvents" + responses: + "202": + description: Inbox archived events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /unsubscribe/email: + get: + summary: Email unsubscribe page + description: | + Public endpoint for email unsubscribe links. Extracts user and campaign information + from the encoded URL and displays an unsubscribe confirmation page. + operationId: emailUnsubscribe + tags: + - Subscriptions + parameters: + - name: link + in: query + required: true + schema: + type: string + description: Encoded unsubscribe link with user and campaign data + responses: + "200": + description: Unsubscribe confirmation page + content: + text/html: + schema: + type: string + + /preferences/{projectID}/{userID}: + get: + summary: Subscription preferences page + description: | + Public endpoint for users to view and manage their subscription preferences. + Shows all public subscriptions with current user state. + operationId: getPreferencesPage + tags: + - Subscriptions + parameters: + - name: projectID + in: path + required: true + schema: + type: string + format: uuid + description: The project ID + - name: userID + in: path + required: true + schema: + type: string + format: uuid + description: The user ID + responses: + "200": + description: Subscription preferences page + content: + text/html: + schema: + type: string + + post: + summary: Update subscription preferences + description: | + Handles form submission to update user subscription preferences. + Redirects back to preferences page with success indicator. + operationId: updatePreferences + tags: + - Subscriptions + parameters: + - name: projectID + in: path + required: true + schema: + type: string + format: uuid + description: The project ID + - name: userID + in: path + required: true + schema: + type: string + format: uuid + description: The user ID + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + subscriptionIds: + type: array + items: + type: string + format: uuid + description: Array of subscription IDs to keep subscribed + responses: + "302": + description: Redirect to preferences page with success indicator + "200": + description: Success response (for htmx partial updates) + content: + text/html: + schema: + type: string + + /api/client/projects/{projectID}/users/devices: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Register device + description: Register or update a device's push subscription. The project is taken from the projectID path parameter. + operationId: registerDevice + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceRegistration" + responses: + "201": + description: Device registered successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/push/vapid: + parameters: + - $ref: "#/components/parameters/ProjectID" + get: + summary: Get VAPID public key + description: Retrieves the VAPID public key for push notifications + operationId: getVapidPublicKey + tags: + - Client + security: + - HttpBearerAuth: [] + responses: + "200": + description: VAPID public key retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/VapidPublicKey" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/auth-methods/{authMethodID}/sessions: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mint a session token + description: | + Mints a short-lived session token for an end user, to be used from the + client. Called server-side with an API key; the session's permissions are + defined by the session auth method (policy) named in the path. + operationId: createSession + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: authMethodID + in: path + required: true + description: The session auth method (policy) that defines what the session may do. + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSession" + responses: + "201": + description: Session token minted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/SessionToken" + default: + $ref: "#/components/responses/Error" + +components: + parameters: + ProjectID: + name: projectID + in: path + required: true + schema: + type: string + format: uuid + description: The project the request acts on. Must match the project the presented credential is authorized for. + + Limit: + name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + x-go-type: PaginationLimit + description: Maximum number of items to return + + Offset: + name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + x-go-type: PaginationOffset + description: Number of items to skip + + responses: + Error: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + + schemas: + CreateSession: + type: object + description: Request to mint a session token for an end user. + required: + - user_id + properties: + user_id: + type: string + description: The end user's external identifier (the session subject). + example: "user_12345" + + SessionToken: + type: object + required: + - token + - expires_at + properties: + token: + type: string + description: The signed session token (a bearer token for the Client API). + expires_at: + type: string + format: date-time + + Channel: + type: string + enum: [email, sms, push, inbox] + + Problem: + type: object + required: + - title + - detail + properties: + title: + type: string + description: | + A short summary of the problem type. Written in English and readable for engineers, usually not suited for non technical stakeholders and not localized. + example: some title for the error situation + detail: + type: string + description: | + A human readable explanation specific to this occurrence of the problem that is helpful to locate the problem and give advice on how to proceed. Written in English and readable for engineers, usually not suited for non technical stakeholders and not localized. + example: some description for the error situation + + ExternalID: + type: object + description: An external identifier with source and optional metadata + required: + - external_id + properties: + source: + type: string + default: "default" + example: "default" + description: Source of the identifier (e.g. "default", "anonymous", or a custom source). Defaults to "default" if not provided. + external_id: + type: string + minLength: 1 + example: "user_12345" + description: The external identifier value + metadata: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + description: Optional metadata associated with this identifier + + ExternalIDResponse: + type: object + description: An external identifier as returned in responses, including database ID and timestamps + required: + - id + - source + - external_id + - created_at + - updated_at + properties: + id: + type: string + format: uuid + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + source: + type: string + example: "default" + external_id: + type: string + example: "user_12345" + metadata: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + created_at: + type: string + format: date-time + example: "2025-11-19T14:18:42.960Z" + updated_at: + type: string + format: date-time + example: "2025-11-23T17:20:00.021Z" + + User: + type: object + required: + - id + - project_id + - identifier + - data + - has_push_device + - version + - created_at + - updated_at + properties: + id: + type: string + format: uuid + example: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" + project_id: + type: string + format: uuid + example: "4c9d3163-7b64-4f9e-9068-d2e4b96be56b" + identifier: + type: array + items: + $ref: "#/components/schemas/ExternalIDResponse" + description: External identifiers associated with this user + email: + type: string + format: email + example: "user@example.com" + x-go-type: string + phone: + type: string + example: "+1234567890" + description: E.164 formatted phone number + x-go-type: string + format: phone + data: + type: object + additionalProperties: true + x-go-type: map[string]any + example: + first_name: "John" + last_name: "Doe" + timezone: + type: string + example: "America/New_York" + locale: + type: string + example: "en" + has_push_device: + type: boolean + example: false + version: + type: integer + example: 1 + x-go-type: int32 + created_at: + type: string + format: date-time + example: "2025-11-19T14:18:42.960Z" + updated_at: + type: string + format: date-time + example: "2025-11-23T17:20:00.021Z" + + UserIdentifier: + type: array + description: One or more external identifiers to identify the user + minItems: 1 + items: + $ref: "#/components/schemas/ExternalID" + + OrganizationIdentifier: + type: array + description: One or more external identifiers to identify the organization + minItems: 1 + items: + $ref: "#/components/schemas/ExternalID" + + DeviceRegistration: + type: object + required: + - identifier + - device_id + - config + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + device_id: + type: string + os: + type: string + enum: [web, ios, android] + os_version: + type: string + model: + type: string + app_version: + type: string + data: + type: object + additionalProperties: true + nullable: true + x-go-type: json.RawMessage + example: + app_channel: "beta" + locale: "en-US" + config: + type: object + properties: + token: + type: string + description: Device token for FCM or APNs + endpoint: + type: string + description: Web Push subscription endpoint URL + expiration_time: + type: string + format: date-time + keys: + type: object + required: [p256dh, auth] + properties: + p256dh: + type: string + auth: + type: string + + VapidPublicKey: + type: object + required: + - public_key + properties: + public_key: + type: string + description: The VAPID public key + + IdentifyRequest: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + email: + type: string + nullable: true + format: email + example: "user@example.com" + x-go-type: string + phone: + type: string + nullable: true + example: "+31612345678" + description: E.164 formatted phone number + x-go-type: string + format: phone + timezone: + type: string + nullable: true + example: "Europe/Amsterdam" + locale: + type: string + nullable: true + example: "nl-NL" + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + first_name: "John" + last_name: "Smith" + has_completed_onboarding: true + description: User-specific attributes + + PostEventsRequest: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/Event" + + Event: + type: object + required: + - name + - data + properties: + name: + type: string + example: "purchase_completed" + description: The name of the event + identifier: + $ref: "#/components/schemas/UserIdentifier" + match: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + description: >- + JSONB containment filter to match users by their data attributes. + Mutually exclusive with identifier. When set, the event is delivered + to every user whose data column contains the given key/value pairs. + example: + plan: "enterprise" + data: + type: object + additionalProperties: true + x-go-type: map[string]any + example: + product_id: "prod_789" + amount: 99.99 + description: Event-specific data + + InboxMessageCreate: + type: object + required: + - target + - channel + - identifier + properties: + target: + $ref: "#/components/schemas/UserIdentifier" + identifier: + $ref: "#/components/schemas/ExternalID" + description: External identifier for the message. Required for idempotency and traceability back to the origin source. + channel: + $ref: "#/components/schemas/Channel" + sender_identity_id: + type: string + format: uuid + nullable: true + description: Required for email and sms messages. Push uses project push provider settings. + campaign_id: + type: string + format: uuid + nullable: true + broadcast_id: + type: string + format: uuid + nullable: true + content: + type: object + additionalProperties: true + nullable: true + x-go-type: json.RawMessage + description: Channel-specific payload content. + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + tags: + type: array + items: + type: string + priority: + type: integer + minimum: 1 + maximum: 5 + default: 3 + x-go-type: int16 + source: + type: string + nullable: true + scheduled_at: + type: string + format: date-time + nullable: true + expires_at: + type: string + format: date-time + nullable: true + + OrganizationInboxMessageCreate: + type: object + required: + - target + - channel + - identifier + properties: + target: + $ref: "#/components/schemas/OrganizationIdentifier" + identifier: + $ref: "#/components/schemas/ExternalID" + description: External identifier for the message. Required for idempotency and traceability back to the origin source. + channel: + $ref: "#/components/schemas/Channel" + sender_identity_id: + type: string + format: uuid + nullable: true + description: Required for email and sms messages. Push uses project push provider settings. + campaign_id: + type: string + format: uuid + nullable: true + broadcast_id: + type: string + format: uuid + nullable: true + content: + type: object + additionalProperties: true + nullable: true + x-go-type: json.RawMessage + description: Channel-specific payload content. + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + tags: + type: array + items: + type: string + priority: + type: integer + minimum: 1 + maximum: 5 + default: 3 + x-go-type: int16 + source: + type: string + nullable: true + scheduled_at: + type: string + format: date-time + nullable: true + expires_at: + type: string + format: date-time + nullable: true + + PostUserInboxMessagesRequest: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/InboxMessageCreate" + + PostOrganizationInboxMessagesRequest: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/OrganizationInboxMessageCreate" + + UserInboxMessageRef: + type: object + required: + - target + - message_id + properties: + target: + $ref: "#/components/schemas/UserIdentifier" + message_id: + type: string + format: uuid + + OrganizationInboxMessageRef: + type: object + required: + - target + - message_id + properties: + target: + $ref: "#/components/schemas/OrganizationIdentifier" + message_id: + type: string + format: uuid + + UserInboxMessageEvents: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/UserInboxMessageRef" + + OrganizationInboxMessageEvents: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/OrganizationInboxMessageRef" + + InboxMessage: + type: object + required: + - id + - project_id + - channel + - content + - data + - tags + - priority + - scheduled_at + - created_at + - updated_at + properties: + id: + type: string + format: uuid + project_id: + type: string + format: uuid + user_id: + type: string + format: uuid + organization_id: + type: string + format: uuid + external_id: + type: string + nullable: true + description: External identifier for the message, if one was provided at creation time. + channel: + $ref: "#/components/schemas/Channel" + sender_identity_id: + type: string + format: uuid + nullable: true + campaign_id: + type: string + format: uuid + nullable: true + broadcast_id: + type: string + format: uuid + nullable: true + content: + type: object + additionalProperties: true + x-go-type: json.RawMessage + data: + type: object + additionalProperties: true + x-go-type: json.RawMessage + tags: + type: array + items: + type: string + priority: + type: integer + minimum: 1 + maximum: 5 + x-go-type: int16 + source: + type: string + nullable: true + scheduled_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + nullable: true + read_at: + type: string + format: date-time + nullable: true + archived_at: + type: string + format: date-time + nullable: true + sent_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + InboxMessageList: + type: object + required: + - results + - total + - limit + - offset + properties: + results: + type: array + items: + $ref: "#/components/schemas/InboxMessage" + total: + type: integer + limit: + type: integer + offset: + type: integer + + InboxCount: + type: object + required: + - unread + - total + properties: + unread: + type: integer + total: + type: integer + + Organization: + type: object + required: + - id + - project_id + - identifier + - data + - version + - created_at + - updated_at + properties: + id: + type: string + format: uuid + example: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" + project_id: + type: string + format: uuid + example: "4c9d3163-7b64-4f9e-9068-d2e4b96be56b" + identifier: + type: array + items: + $ref: "#/components/schemas/ExternalIDResponse" + description: External identifiers associated with this organization + name: + type: string + example: "Acme Corp" + data: + type: object + additionalProperties: true + x-go-type: map[string]any + example: + industry: "technology" + size: "enterprise" + version: + type: integer + example: 1 + x-go-type: int32 + created_at: + type: string + format: date-time + example: "2025-11-19T14:18:42.960Z" + updated_at: + type: string + format: date-time + example: "2025-11-23T17:20:00.021Z" + + OrganizationRequest: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + name: + type: string + nullable: true + example: "Acme Corp" + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + industry: "technology" + size: "enterprise" + + OrganizationUserRequest: + type: object + required: + - organization + - user + properties: + organization: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + user: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + role: "admin" + department: "engineering" + description: Organization-specific data for this user + + DeleteUserRequest: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + + DeleteOrganizationRequest: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + + RemoveOrganizationUserRequest: + type: object + required: + - organization + - user + properties: + organization: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + user: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + + PostOrganizationEventsRequest: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/OrganizationEvent" + + OrganizationEvent: + type: object + required: + - name + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + match: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + description: >- + JSONB containment filter to match organizations by their data attributes. + Mutually exclusive with identifier. When set, the event is delivered + to every organization whose data column contains the given key/value pairs. + example: + plan: "enterprise" + name: + type: string + example: "subscription_upgraded" + description: The name of the event + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + plan: "enterprise" + seats: 100 + description: Event-specific data + + UpsertUserScheduledRequest: + type: object + required: + - name + properties: + name: + type: string + example: "renewal_date" + description: The name of the scheduled resource + identifier: + $ref: "#/components/schemas/UserIdentifier" + scheduled_at: + type: string + format: date-time + nullable: true + example: "2025-12-25T10:00:00Z" + description: The time at which the scheduled resource is set to trigger. Required for single schedules. + start_at: + type: string + format: date-time + nullable: true + example: "2025-01-01T00:00:00Z" + description: Start time for recurring schedules. If omitted for recurring schedules, defaults to now. + interval: + type: string + nullable: true + example: "24h" + description: Interval for recurring schedules. When set, the schedule type is automatically set to recurring. + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + plan: "pro" + amount: 29.99 + description: Scheduled resource data + + UpsertOrganizationScheduledRequest: + type: object + required: + - name + - identifier + properties: + name: + type: string + example: "contract_renewal" + description: The name of the scheduled resource + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + scheduled_at: + type: string + format: date-time + nullable: true + example: "2025-12-25T10:00:00Z" + description: The time at which the scheduled resource is set to trigger. Required for single schedules. + start_at: + type: string + format: date-time + nullable: true + example: "2025-01-01T00:00:00Z" + description: Start time for recurring schedules. If omitted for recurring schedules, defaults to now. + interval: + type: string + nullable: true + example: "24h" + description: Interval for recurring schedules. When set, the schedule type is automatically set to recurring. + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + contract_type: "enterprise" + seats: 100 + description: Scheduled resource data + + DeleteUserScheduledRequest: + type: object + required: + - name + properties: + name: + type: string + example: "renewal_date" + description: The name of the scheduled resource to delete + identifier: + $ref: "#/components/schemas/UserIdentifier" + + ScheduledAccepted: + type: object + required: + - id + - name + - scheduled_at + properties: + id: + type: string + format: uuid + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + description: The unique identifier for the scheduled instance + name: + type: string + example: "trial_end" + description: The name of the scheduled resource + scheduled_at: + type: string + format: date-time + example: "2025-01-15T09:00:00Z" + description: The time at which the scheduled resource is set to trigger + data: + type: object + additionalProperties: true + nullable: true + x-go-type: "map[string]any" + description: Scheduled resource data + + DeleteOrganizationScheduledRequest: + type: object + required: + - name + - identifier + properties: + name: + type: string + example: "contract_renewal" + description: The name of the scheduled resource to delete + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + + securitySchemes: + HttpBearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT token for API authentication From a3ec2f315080060313ef5650c4af2c94bb9935d0 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Wed, 24 Jun 2026 14:32:16 +0200 Subject: [PATCH 4/7] feat: add inbox facade for user and organization inboxes Expose the complete inbox surface on the hand-written facade, mirroring the spec's user and organization inbox endpoints: - PostUserInboxMessages / PostOrganizationInboxMessages - GetUserInbox / GetOrganizationInbox (with InboxQuery filters) - GetUserInboxCount / GetOrganizationInboxCount (with InboxCountQuery) - MarkUserInboxRead / MarkOrganizationInboxRead - MarkUserInboxArchived / MarkOrganizationInboxArchived Add ergonomic facade types (InboxMessageCreate, InboxMessageRef, the query structs, and the InboxMessage/InboxMessageList/InboxCount responses) plus a url.Values query encoder, all kept in lockstep with the generated types via conformance tests. Document the inbox methods in the README. --- README.md | 113 ++++++++++++++++++- conformance_test.go | 108 ++++++++++++++++++ inbox_query_test.go | 77 +++++++++++++ inbox_test.go | 111 +++++++++++++++++++ models.go | 259 +++++++++++++++++++++++++++++++++++++++++++- organizations.go | 40 +++++++ users.go | 39 +++++++ 7 files changed, 745 insertions(+), 2 deletions(-) create mode 100644 inbox_query_test.go create mode 100644 inbox_test.go diff --git a/README.md b/README.md index ccc36d7..faad2e2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/lunogram/go-sdk.svg)](https://pkg.go.dev/github.com/lunogram/go-sdk) -Go client library for the [Lunogram](https://lunogram.com) Client API. Manage user profiles, organizations, events, and scheduled resources from any Go application. +Go client library for the [Lunogram](https://lunogram.com) Client API. Manage user profiles, organizations, events, inbox messages, and scheduled resources from any Go application. ## Installation @@ -220,6 +220,117 @@ err := client.PostOrganizationEvents(ctx, []lunogram.OrganizationEvent{ }) ``` +## Inbox + +Inbox messages are delivered to users or organizations on a specific channel +(`email`, `sms`, `push`, or `inbox`). Creating, reading, and archiving messages +are processed asynchronously; querying and counting return immediately. + +### Create user inbox messages + +`Identifier` is the message's own external identifier, used for idempotency and +traceability; `Target` identifies the recipient. + +```go +err := client.PostUserInboxMessages(ctx, []lunogram.InboxMessageCreate{ + { + Target: []lunogram.ExternalID{{ExternalID: "user_123"}}, + Identifier: lunogram.ExternalID{ExternalID: "msg_welcome_1"}, + Channel: lunogram.ChannelInbox, + Content: json.RawMessage(`{"title": "Welcome!", "body": "Thanks for joining."}`), + Tags: []string{"onboarding"}, + }, +}) +``` + +### Query the user inbox + +`Source`, `ExternalID`, and `Channel` are required; the remaining fields are +optional filters. + +```go +list, err := client.GetUserInbox(ctx, &lunogram.InboxQuery{ + Source: "default", + ExternalID: "user_123", + Channel: lunogram.ChannelInbox, + Status: &[]lunogram.InboxStatus{lunogram.InboxStatusUnread}[0], +}) +for _, msg := range list.Results { + fmt.Println(msg.ID, msg.Priority) +} +``` + +### Count user inbox messages + +```go +count, err := client.GetUserInboxCount(ctx, &lunogram.InboxCountQuery{ + Source: "default", + ExternalID: "user_123", + Channel: lunogram.ChannelInbox, +}) +fmt.Printf("%d unread of %d total\n", count.Unread, count.Total) +``` + +### Mark user messages read or archived + +```go +err := client.MarkUserInboxRead(ctx, []lunogram.InboxMessageRef{ + { + Target: []lunogram.ExternalID{{ExternalID: "user_123"}}, + MessageID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }, +}) + +err = client.MarkUserInboxArchived(ctx, []lunogram.InboxMessageRef{ + { + Target: []lunogram.ExternalID{{ExternalID: "user_123"}}, + MessageID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }, +}) +``` + +### Organization inbox + +The organization inbox mirrors the user inbox. Create, query, count, and mark +messages with the organization-scoped methods: + +```go +err := client.PostOrganizationInboxMessages(ctx, []lunogram.OrganizationInboxMessageCreate{ + { + Target: []lunogram.ExternalID{{ExternalID: "org_456"}}, + Identifier: lunogram.ExternalID{ExternalID: "msg_renewal_1"}, + Channel: lunogram.ChannelInbox, + Content: json.RawMessage(`{"title": "Contract renewal"}`), + }, +}) + +list, err := client.GetOrganizationInbox(ctx, &lunogram.InboxQuery{ + Source: "default", + ExternalID: "org_456", + Channel: lunogram.ChannelInbox, +}) + +count, err := client.GetOrganizationInboxCount(ctx, &lunogram.InboxCountQuery{ + Source: "default", + ExternalID: "org_456", + Channel: lunogram.ChannelInbox, +}) + +err = client.MarkOrganizationInboxRead(ctx, []lunogram.OrganizationInboxMessageRef{ + { + Target: []lunogram.ExternalID{{ExternalID: "org_456"}}, + MessageID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }, +}) + +err = client.MarkOrganizationInboxArchived(ctx, []lunogram.OrganizationInboxMessageRef{ + { + Target: []lunogram.ExternalID{{ExternalID: "org_456"}}, + MessageID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }, +}) +``` + ## Scheduled resources Scheduled resources trigger journey entrance recalculation at a specific time or on a recurring interval. diff --git a/conformance_test.go b/conformance_test.go index 531745b..7843ea2 100644 --- a/conformance_test.go +++ b/conformance_test.go @@ -229,4 +229,112 @@ func TestFacadeTypesConformToSpec(t *testing.T) { UpdatedAt: at, }, &gen.Organization{}) }) + + // Inbox request types. The facade carries UUID fields as strings; a UUID + // marshals to the same JSON string, so the wire shape is identical. + uuidStr := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + priority := int16(4) + + t.Run("InboxMessageCreate", func(t *testing.T) { + roundTrip(t, "InboxMessageCreate", &InboxMessageCreate{ + Target: []ExternalID{{ExternalID: "u1"}}, + Identifier: ExternalID{ExternalID: "msg_1"}, + Channel: ChannelInbox, + SenderIdentityID: String(uuidStr), + CampaignID: String(uuidStr), + BroadcastID: String(uuidStr), + Content: json.RawMessage(`{"title":"Hi"}`), + Data: map[string]any{"k": "v"}, + Tags: []string{"welcome"}, + Priority: &priority, + Source: String("crm"), + ScheduledAt: &at, + ExpiresAt: &at, + }, &gen.InboxMessageCreate{}) + }) + + t.Run("OrganizationInboxMessageCreate", func(t *testing.T) { + roundTrip(t, "OrganizationInboxMessageCreate", &OrganizationInboxMessageCreate{ + Target: []ExternalID{{ExternalID: "o1"}}, + Identifier: ExternalID{ExternalID: "msg_1"}, + Channel: ChannelInbox, + SenderIdentityID: String(uuidStr), + CampaignID: String(uuidStr), + BroadcastID: String(uuidStr), + Content: json.RawMessage(`{"title":"Hi"}`), + Data: map[string]any{"k": "v"}, + Tags: []string{"welcome"}, + Priority: &priority, + Source: String("crm"), + ScheduledAt: &at, + ExpiresAt: &at, + }, &gen.OrganizationInboxMessageCreate{}) + }) + + t.Run("InboxMessageRef", func(t *testing.T) { + roundTrip(t, "InboxMessageRef", &InboxMessageRef{ + Target: []ExternalID{{ExternalID: "u1"}}, + MessageID: uuidStr, + }, &gen.UserInboxMessageRef{}) + }) + + t.Run("OrganizationInboxMessageRef", func(t *testing.T) { + roundTrip(t, "OrganizationInboxMessageRef", &OrganizationInboxMessageRef{ + Target: []ExternalID{{ExternalID: "o1"}}, + MessageID: uuidStr, + }, &gen.OrganizationInboxMessageRef{}) + }) + + // Inbox response types. + t.Run("InboxMessage", func(t *testing.T) { + roundTrip(t, "InboxMessage", &InboxMessage{ + ID: uuidStr, + ProjectID: "4c9d3163-7b64-4f9e-9068-d2e4b96be56b", + UserID: String("9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"), + ExternalID: String("msg_1"), + Channel: ChannelInbox, + SenderIdentityID: String(uuidStr), + CampaignID: String(uuidStr), + BroadcastID: String(uuidStr), + Content: json.RawMessage(`{"title":"Hi"}`), + Data: json.RawMessage(`{"k":"v"}`), + Tags: []string{"welcome"}, + Priority: 3, + Source: String("crm"), + ScheduledAt: at, + ExpiresAt: &at, + ReadAt: &at, + ArchivedAt: &at, + SentAt: &at, + CreatedAt: at, + UpdatedAt: at, + }, &gen.InboxMessage{}) + }) + + t.Run("InboxMessageList", func(t *testing.T) { + roundTrip(t, "InboxMessageList", &InboxMessageList{ + Results: []InboxMessage{{ + ID: uuidStr, + ProjectID: "4c9d3163-7b64-4f9e-9068-d2e4b96be56b", + Channel: ChannelInbox, + Content: json.RawMessage(`{}`), + Data: json.RawMessage(`{}`), + Tags: []string{}, + Priority: 3, + ScheduledAt: at, + CreatedAt: at, + UpdatedAt: at, + }}, + Total: 1, + Limit: 20, + Offset: 0, + }, &gen.InboxMessageList{}) + }) + + t.Run("InboxCount", func(t *testing.T) { + roundTrip(t, "InboxCount", &InboxCount{ + Unread: 3, + Total: 10, + }, &gen.InboxCount{}) + }) } diff --git a/inbox_query_test.go b/inbox_query_test.go new file mode 100644 index 0000000..c590a24 --- /dev/null +++ b/inbox_query_test.go @@ -0,0 +1,77 @@ +package lunogram + +import ( + "testing" +) + +// TestInboxQueryValues checks that the query encoder emits the exact parameter +// names the spec defines (source, external_id, channel, status, tags, +// message_source, priority, limit, offset) and joins tags with commas. These +// names must match the generated GetUserInboxParams/GetOrganizationInboxParams +// form tags; a mismatch would silently drop filters on the wire. +func TestInboxQueryValues(t *testing.T) { + status := InboxStatusUnread + source := "crm" + priority := 4 + limit := 50 + offset := 10 + + q := &InboxQuery{ + Source: "default", + ExternalID: "user_123", + Channel: ChannelInbox, + Status: &status, + Tags: []string{"a", "b"}, + MessageSource: &source, + Priority: &priority, + Limit: &limit, + Offset: &offset, + } + + got := q.values() + want := map[string]string{ + "source": "default", + "external_id": "user_123", + "channel": "inbox", + "status": "unread", + "tags": "a,b", + "message_source": "crm", + "priority": "4", + "limit": "50", + "offset": "10", + } + for k, v := range want { + if got.Get(k) != v { + t.Errorf("values()[%q] = %q, want %q", k, got.Get(k), v) + } + } + if len(got) != len(want) { + t.Errorf("values() produced %d params, want %d: %v", len(got), len(want), got) + } +} + +// TestInboxQueryValuesOmitsOptional verifies that optional filters are omitted +// when unset, leaving only the three required parameters. +func TestInboxQueryValuesOmitsOptional(t *testing.T) { + q := &InboxQuery{Source: "default", ExternalID: "user_123", Channel: ChannelInbox} + got := q.values() + if len(got) != 3 { + t.Errorf("values() produced %d params, want 3: %v", len(got), got) + } +} + +// TestInboxCountQueryValues checks the count query encoder against the spec's +// required parameters (source, external_id, channel). +func TestInboxCountQueryValues(t *testing.T) { + q := &InboxCountQuery{Source: "default", ExternalID: "user_123", Channel: ChannelInbox} + got := q.values() + want := map[string]string{"source": "default", "external_id": "user_123", "channel": "inbox"} + for k, v := range want { + if got.Get(k) != v { + t.Errorf("values()[%q] = %q, want %q", k, got.Get(k), v) + } + } + if len(got) != len(want) { + t.Errorf("values() produced %d params, want %d: %v", len(got), len(want), got) + } +} diff --git a/inbox_test.go b/inbox_test.go new file mode 100644 index 0000000..b068242 --- /dev/null +++ b/inbox_test.go @@ -0,0 +1,111 @@ +package lunogram_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + lunogram "github.com/lunogram/go-sdk" +) + +func TestUserInbox(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + // Ensure the recipient exists. + _, err := client.UpsertUser(ctx, &lunogram.UpsertUserRequest{ + Identifier: []lunogram.ExternalID{ + {ExternalID: "inbox_user_1"}, + }, + Email: lunogram.String("inbox@example.com"), + }) + require.NoError(t, err, "UpsertUser for inbox target should succeed") + + // Create an inbox message. + err = client.PostUserInboxMessages(ctx, []lunogram.InboxMessageCreate{ + { + Target: []lunogram.ExternalID{{ExternalID: "inbox_user_1"}}, + Identifier: lunogram.ExternalID{ExternalID: "inbox_user_msg_1"}, + Channel: lunogram.ChannelInbox, + Content: json.RawMessage(`{"title":"Welcome","body":"Hello"}`), + Tags: []string{"onboarding"}, + }, + }) + require.NoError(t, err, "PostUserInboxMessages should succeed") + + // Query the inbox. + list, err := client.GetUserInbox(ctx, &lunogram.InboxQuery{ + Source: "default", + ExternalID: "inbox_user_1", + Channel: lunogram.ChannelInbox, + }) + require.NoError(t, err, "GetUserInbox should succeed") + require.NotNil(t, list) + + // Count the inbox. + _, err = client.GetUserInboxCount(ctx, &lunogram.InboxCountQuery{ + Source: "default", + ExternalID: "inbox_user_1", + Channel: lunogram.ChannelInbox, + }) + require.NoError(t, err, "GetUserInboxCount should succeed") + + t.Cleanup(func() { + _ = client.DeleteUser(ctx, &lunogram.DeleteUserRequest{ + Identifier: []lunogram.ExternalID{ + {ExternalID: "inbox_user_1"}, + }, + }) + }) +} + +func TestOrganizationInbox(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + // Ensure the recipient exists. + _, err := client.UpsertOrganization(ctx, &lunogram.UpsertOrganizationRequest{ + Identifier: []lunogram.ExternalID{ + {ExternalID: "inbox_org_1"}, + }, + Name: lunogram.String("Inbox Org"), + }) + require.NoError(t, err, "UpsertOrganization for inbox target should succeed") + + // Create an inbox message. + err = client.PostOrganizationInboxMessages(ctx, []lunogram.OrganizationInboxMessageCreate{ + { + Target: []lunogram.ExternalID{{ExternalID: "inbox_org_1"}}, + Identifier: lunogram.ExternalID{ExternalID: "inbox_org_msg_1"}, + Channel: lunogram.ChannelInbox, + Content: json.RawMessage(`{"title":"Renewal"}`), + }, + }) + require.NoError(t, err, "PostOrganizationInboxMessages should succeed") + + // Query the inbox. + _, err = client.GetOrganizationInbox(ctx, &lunogram.InboxQuery{ + Source: "default", + ExternalID: "inbox_org_1", + Channel: lunogram.ChannelInbox, + }) + require.NoError(t, err, "GetOrganizationInbox should succeed") + + // Count the inbox. + _, err = client.GetOrganizationInboxCount(ctx, &lunogram.InboxCountQuery{ + Source: "default", + ExternalID: "inbox_org_1", + Channel: lunogram.ChannelInbox, + }) + require.NoError(t, err, "GetOrganizationInboxCount should succeed") + + t.Cleanup(func() { + _ = client.DeleteOrganization(ctx, &lunogram.DeleteOrganizationRequest{ + Identifier: []lunogram.ExternalID{ + {ExternalID: "inbox_org_1"}, + }, + }) + }) +} diff --git a/models.go b/models.go index d2c3c8b..972392b 100644 --- a/models.go +++ b/models.go @@ -1,6 +1,12 @@ package lunogram -import "time" +import ( + "encoding/json" + "net/url" + "strconv" + "strings" + "time" +) // ExternalID identifies a user or organization by an external system identifier. type ExternalID struct { @@ -215,3 +221,254 @@ type ScheduledAccepted struct { ScheduledAt time.Time `json:"scheduled_at"` Data map[string]any `json:"data,omitempty"` } + +// Channel is the delivery channel of an inbox message. +type Channel string + +// Supported delivery channels. +const ( + ChannelEmail Channel = "email" + ChannelSMS Channel = "sms" + ChannelPush Channel = "push" + ChannelInbox Channel = "inbox" +) + +// InboxStatus filters inbox messages by their read/archive state. +type InboxStatus string + +// Inbox message statuses. +const ( + InboxStatusUnread InboxStatus = "unread" + InboxStatusRead InboxStatus = "read" + InboxStatusArchived InboxStatus = "archived" +) + +// InboxMessageCreate creates a single inbox message for a user. +type InboxMessageCreate struct { + // Target identifies the user the message is delivered to. + Target []ExternalID `json:"target"` + + // Identifier is the external identifier of the message itself. It is + // required for idempotency and traceability back to the origin source. + Identifier ExternalID `json:"identifier"` + + // Channel is the delivery channel (email, sms, push, inbox). + Channel Channel `json:"channel"` + + // SenderIdentityID is required for email and sms messages. Push uses the + // project's push provider settings. + SenderIdentityID *string `json:"sender_identity_id,omitempty"` + + // CampaignID associates the message with a campaign. + CampaignID *string `json:"campaign_id,omitempty"` + + // BroadcastID associates the message with a broadcast. + BroadcastID *string `json:"broadcast_id,omitempty"` + + // Content is the channel-specific payload content. + Content json.RawMessage `json:"content,omitempty"` + + // Data holds arbitrary message attributes. + Data map[string]any `json:"data,omitempty"` + + // Tags label the message for filtering. + Tags []string `json:"tags,omitempty"` + + // Priority is a value from 1 to 5. Defaults to 3 when omitted. + Priority *int16 `json:"priority,omitempty"` + + // Source is an optional origin label for the message. + Source *string `json:"source,omitempty"` + + // ScheduledAt is when the message should be delivered. + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` + + // ExpiresAt is when the message stops being visible. + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// OrganizationInboxMessageCreate creates a single inbox message for an organization. +type OrganizationInboxMessageCreate struct { + // Target identifies the organization the message is delivered to. + Target []ExternalID `json:"target"` + + // Identifier is the external identifier of the message itself. It is + // required for idempotency and traceability back to the origin source. + Identifier ExternalID `json:"identifier"` + + // Channel is the delivery channel (email, sms, push, inbox). + Channel Channel `json:"channel"` + + // SenderIdentityID is required for email and sms messages. Push uses the + // project's push provider settings. + SenderIdentityID *string `json:"sender_identity_id,omitempty"` + + // CampaignID associates the message with a campaign. + CampaignID *string `json:"campaign_id,omitempty"` + + // BroadcastID associates the message with a broadcast. + BroadcastID *string `json:"broadcast_id,omitempty"` + + // Content is the channel-specific payload content. + Content json.RawMessage `json:"content,omitempty"` + + // Data holds arbitrary message attributes. + Data map[string]any `json:"data,omitempty"` + + // Tags label the message for filtering. + Tags []string `json:"tags,omitempty"` + + // Priority is a value from 1 to 5. Defaults to 3 when omitted. + Priority *int16 `json:"priority,omitempty"` + + // Source is an optional origin label for the message. + Source *string `json:"source,omitempty"` + + // ScheduledAt is when the message should be delivered. + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` + + // ExpiresAt is when the message stops being visible. + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// InboxMessageRef references a single user inbox message to mark read or archived. +type InboxMessageRef struct { + // Target identifies the user the message belongs to. + Target []ExternalID `json:"target"` + + // MessageID is the UUID of the message to mark. + MessageID string `json:"message_id"` +} + +// OrganizationInboxMessageRef references a single organization inbox message to +// mark read or archived. +type OrganizationInboxMessageRef struct { + // Target identifies the organization the message belongs to. + Target []ExternalID `json:"target"` + + // MessageID is the UUID of the message to mark. + MessageID string `json:"message_id"` +} + +// InboxQuery holds the filters for querying user or organization inbox messages. +// Source, ExternalID, and Channel are required by the API; the remaining fields +// are optional filters. +type InboxQuery struct { + // Source of the target's external identifier (e.g. "default"). + Source string + + // ExternalID of the target whose inbox is queried. + ExternalID string + + // Channel restricts the query to a single delivery channel. Required. + Channel Channel + + // Status filters by read/archive state when set. + Status *InboxStatus + + // Tags filters to messages carrying all of the given tags. + Tags []string + + // MessageSource filters by the message's origin source label when set. + MessageSource *string + + // Priority filters to messages with the given priority when set. + Priority *int + + // Limit caps the number of messages returned (1–100, default 20). + Limit *int + + // Offset skips the given number of messages for pagination. + Offset *int +} + +// InboxCountQuery holds the filters for counting user or organization inbox +// messages. All three fields are required by the API. +type InboxCountQuery struct { + // Source of the target's external identifier (e.g. "default"). + Source string + + // ExternalID of the target whose inbox is counted. + ExternalID string + + // Channel restricts the count to a single delivery channel. + Channel Channel +} + +// values encodes the query as URL query parameters using the spec's parameter +// names (source, external_id, channel, status, tags, message_source, priority, +// limit, offset). Comma-separated tags are joined per the spec's tag filter. +func (q *InboxQuery) values() url.Values { + v := url.Values{} + v.Set("source", q.Source) + v.Set("external_id", q.ExternalID) + v.Set("channel", string(q.Channel)) + if q.Status != nil { + v.Set("status", string(*q.Status)) + } + if len(q.Tags) > 0 { + v.Set("tags", strings.Join(q.Tags, ",")) + } + if q.MessageSource != nil { + v.Set("message_source", *q.MessageSource) + } + if q.Priority != nil { + v.Set("priority", strconv.Itoa(*q.Priority)) + } + if q.Limit != nil { + v.Set("limit", strconv.Itoa(*q.Limit)) + } + if q.Offset != nil { + v.Set("offset", strconv.Itoa(*q.Offset)) + } + return v +} + +// values encodes the count query as URL query parameters using the spec's +// parameter names (source, external_id, channel). +func (q *InboxCountQuery) values() url.Values { + v := url.Values{} + v.Set("source", q.Source) + v.Set("external_id", q.ExternalID) + v.Set("channel", string(q.Channel)) + return v +} + +// InboxMessage is an inbox message as returned by the API. +type InboxMessage struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + UserID *string `json:"user_id,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` + ExternalID *string `json:"external_id,omitempty"` + Channel Channel `json:"channel"` + SenderIdentityID *string `json:"sender_identity_id,omitempty"` + CampaignID *string `json:"campaign_id,omitempty"` + BroadcastID *string `json:"broadcast_id,omitempty"` + Content json.RawMessage `json:"content"` + Data json.RawMessage `json:"data"` + Tags []string `json:"tags"` + Priority int16 `json:"priority"` + Source *string `json:"source,omitempty"` + ScheduledAt time.Time `json:"scheduled_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` + SentAt *time.Time `json:"sent_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// InboxMessageList is a paginated list of inbox messages. +type InboxMessageList struct { + Results []InboxMessage `json:"results"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// InboxCount holds unread and total visible inbox message counts. +type InboxCount struct { + Unread int `json:"unread"` + Total int `json:"total"` +} diff --git a/organizations.go b/organizations.go index 29e5208..32466f4 100644 --- a/organizations.go +++ b/organizations.go @@ -36,3 +36,43 @@ func (c *Client) RemoveOrganizationUser(ctx context.Context, req *RemoveOrganiza func (c *Client) PostOrganizationEvents(ctx context.Context, events []OrganizationEvent) error { return c.do(ctx, http.MethodPost, "/organizations/events", events, nil) } + +// PostOrganizationInboxMessages creates one or more inbox messages for +// organizations. Messages are processed asynchronously. +func (c *Client) PostOrganizationInboxMessages(ctx context.Context, messages []OrganizationInboxMessageCreate) error { + return c.do(ctx, http.MethodPost, "/organizations/inbox", messages, nil) +} + +// GetOrganizationInbox returns visible, non-expired inbox messages for the +// organization identified by the query's source and external ID. Source, +// ExternalID, and Channel are required. +func (c *Client) GetOrganizationInbox(ctx context.Context, query *InboxQuery) (*InboxMessageList, error) { + var list InboxMessageList + if err := c.do(ctx, http.MethodGet, "/organizations/inbox?"+query.values().Encode(), nil, &list); err != nil { + return nil, err + } + return &list, nil +} + +// GetOrganizationInboxCount returns unread and total visible inbox message +// counts for the organization identified by the query. Source, ExternalID, and +// Channel are required. +func (c *Client) GetOrganizationInboxCount(ctx context.Context, query *InboxCountQuery) (*InboxCount, error) { + var count InboxCount + if err := c.do(ctx, http.MethodGet, "/organizations/inbox/count?"+query.values().Encode(), nil, &count); err != nil { + return nil, err + } + return &count, nil +} + +// MarkOrganizationInboxRead marks one or more organization inbox messages as +// read. The events are processed asynchronously. +func (c *Client) MarkOrganizationInboxRead(ctx context.Context, messages []OrganizationInboxMessageRef) error { + return c.do(ctx, http.MethodPost, "/organizations/inbox/read", messages, nil) +} + +// MarkOrganizationInboxArchived marks one or more organization inbox messages as +// archived. The events are processed asynchronously. +func (c *Client) MarkOrganizationInboxArchived(ctx context.Context, messages []OrganizationInboxMessageRef) error { + return c.do(ctx, http.MethodPost, "/organizations/inbox/archived", messages, nil) +} diff --git a/users.go b/users.go index 938724b..362c31c 100644 --- a/users.go +++ b/users.go @@ -28,3 +28,42 @@ func (c *Client) DeleteUser(ctx context.Context, req *DeleteUserRequest) error { func (c *Client) PostUserEvents(ctx context.Context, events []Event) error { return c.do(ctx, http.MethodPost, "/users/events", events, nil) } + +// PostUserInboxMessages creates one or more inbox messages for users. Messages +// are processed asynchronously. +func (c *Client) PostUserInboxMessages(ctx context.Context, messages []InboxMessageCreate) error { + return c.do(ctx, http.MethodPost, "/users/inbox", messages, nil) +} + +// GetUserInbox returns visible, non-expired inbox messages for the user +// identified by the query's source and external ID. Source, ExternalID, and +// Channel are required. +func (c *Client) GetUserInbox(ctx context.Context, query *InboxQuery) (*InboxMessageList, error) { + var list InboxMessageList + if err := c.do(ctx, http.MethodGet, "/users/inbox?"+query.values().Encode(), nil, &list); err != nil { + return nil, err + } + return &list, nil +} + +// GetUserInboxCount returns unread and total visible inbox message counts for +// the user identified by the query. Source, ExternalID, and Channel are required. +func (c *Client) GetUserInboxCount(ctx context.Context, query *InboxCountQuery) (*InboxCount, error) { + var count InboxCount + if err := c.do(ctx, http.MethodGet, "/users/inbox/count?"+query.values().Encode(), nil, &count); err != nil { + return nil, err + } + return &count, nil +} + +// MarkUserInboxRead marks one or more user inbox messages as read. The events +// are processed asynchronously. +func (c *Client) MarkUserInboxRead(ctx context.Context, messages []InboxMessageRef) error { + return c.do(ctx, http.MethodPost, "/users/inbox/read", messages, nil) +} + +// MarkUserInboxArchived marks one or more user inbox messages as archived. The +// events are processed asynchronously. +func (c *Client) MarkUserInboxArchived(ctx context.Context, messages []InboxMessageRef) error { + return c.do(ctx, http.MethodPost, "/users/inbox/archived", messages, nil) +} From 9f5da186fab90fc832dade48b4961a40abf74fff Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Wed, 24 Jun 2026 14:37:18 +0200 Subject: [PATCH 5/7] chore(spec): pin the OpenAPI spec to platform release v0.1.0-rc.0 Flip the vendored client spec's provenance from the platform PR #262 branch commit to the published v0.1.0-rc.0 release asset (client.yaml). The spec content is byte-identical, so gen/ is unchanged; this only changes where the spec is sourced from to a stable, versioned, immutable release. spec-sync.yml now resolves the release asset download URL from SOURCE.md instead of the raw-githubusercontent ref URL. --- .github/workflows/spec-sync.yml | 22 +++++++++--------- spec/SOURCE.md | 41 +++++++++++++++------------------ 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index 588389f..e3ce5d4 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -2,13 +2,13 @@ name: Spec Sync # Bump bot for the vendored OpenAPI spec. # -# Re-fetches spec/client.yaml from the pinned ref recorded in spec/SOURCE.md, +# Re-fetches spec/client.yaml from the pinned release recorded in spec/SOURCE.md, # regenerates the low-level layer (gen/), and — if anything changed — opens a # PR. This keeps the SDK in step with the platform spec without a manual fetch. # # Note: this syncs *at the current pin*. Bumping the pin itself (e.g. to a new -# release tag) is a manual edit to spec/SOURCE.md (its raw URL is re-read from -# that file each run, so a bumped pin is picked up automatically). +# release tag) is a manual edit to spec/SOURCE.md (its release asset URL is +# re-read from that file each run, so a bumped pin is picked up automatically). on: schedule: @@ -31,15 +31,15 @@ jobs: with: go-version: "1.25" - # The raw URL (repo + spec path + pinned ref) is single-sourced in - # spec/SOURCE.md; read it from the fenced ```...``` block there so the pin - # only ever lives in one place. + # The release asset URL (repo + pinned tag + asset name) is single-sourced + # in spec/SOURCE.md; read it from the fenced ```...``` block there so the + # pin only ever lives in one place. - name: Resolve spec URL from SOURCE.md id: source run: | - url="$(grep -Eo 'https://raw\.githubusercontent\.com/[^ )]+resources\.yml' spec/SOURCE.md | head -n1)" + url="$(grep -Eo 'https://github\.com/lunogram/platform/releases/download/[^ )]+/client\.yaml' spec/SOURCE.md | head -n1)" if [ -z "$url" ]; then - echo "::error::Could not find the raw spec URL in spec/SOURCE.md" + echo "::error::Could not find the release asset URL in spec/SOURCE.md" exit 1 fi echo "Spec URL: $url" @@ -68,11 +68,11 @@ jobs: body: | Automated spec sync. - Re-fetched `spec/client.yaml` from the pinned ref in `spec/SOURCE.md` - and regenerated `gen/` via `go generate ./...`. + Re-fetched `spec/client.yaml` from the pinned release in + `spec/SOURCE.md` and regenerated `gen/` via `go generate ./...`. Review the diff and merge if the changes look correct. If the spec - should track a different ref, update `spec/SOURCE.md` first. + should track a different release, update `spec/SOURCE.md` first. labels: | dependencies automated diff --git a/spec/SOURCE.md b/spec/SOURCE.md index 3031ac6..998eac2 100644 --- a/spec/SOURCE.md +++ b/spec/SOURCE.md @@ -1,38 +1,35 @@ # Spec source -`spec/client.yaml` is **vendored** (committed) from the Lunogram platform repo. -The SDK's low-level layer (`gen/client.gen.go`) is generated from it via -`make generate` (`go generate ./...`). Do not hand-edit either file. +`spec/client.yaml` is **vendored** (committed) from a Lunogram platform +**release**. The SDK's low-level layer (`gen/client.gen.go`) is generated from +it via `make generate` (`go generate ./...`). Do not hand-edit either file. -| Field | Value | -| ----------- | --------------------------------------------------------- | -| Source repo | https://github.com/lunogram/platform | -| Spec path | `internal/http/controllers/v1/client/oapi/resources.yml` | -| Pinned ref | `a12f901dc98e7ced44efbad27a388e8bf5ee0f3a` | +| Field | Value | +| ----------- | ------------------------------------ | +| Source repo | https://github.com/lunogram/platform | +| Pinned tag | `v0.1.0-rc.0` | +| Spec asset | `client.yaml` | -Raw URL used by `spec-sync.yml` to re-fetch: +Release asset URL used by `spec-sync.yml` to re-fetch: ``` -https://raw.githubusercontent.com/lunogram/platform/a12f901dc98e7ced44efbad27a388e8bf5ee0f3a/internal/http/controllers/v1/client/oapi/resources.yml +https://github.com/lunogram/platform/releases/download/v0.1.0-rc.0/client.yaml ``` ## About the pin -The ref above is the head of platform **PR #262**'s branch — a branch/commit -pin used during development. Pinning to a commit means **no platform release is -required** to consume the spec: any ref (branch, tag, or SHA) is fetchable from -the public repo via the raw URL. - -**TODO:** once the platform cuts a release that ships the client spec (e.g. -`resources.yml` from a `v*.*.*` tag), flip the pinned ref to that tag for a -stable, reproducible source. +The SDK pins a specific platform **release tag**. Every tagged platform release +publishes the client OpenAPI spec as a `client.yaml` asset (see the platform's +`.github/workflows/release.yml` → `openapi-specs` job), so the spec is fetched +from a stable, versioned, immutable source — no platform checkout or branch pin +required. ## How to bump the pin -1. Update the **Pinned ref** value above (and the raw URL). +1. Update the **Pinned tag** value above and the release asset URL to the new tag. 2. Run `make generate` to regenerate `gen/client.gen.go`. 3. Commit `spec/` and `gen/` together. -The `spec-sync.yml` workflow automates re-fetching at the current pin and opens -a PR when the spec or generated output drifts; bumping the pin itself is a -manual edit to this file. +The `spec-sync.yml` workflow re-fetches at the current pin weekly and opens a PR +if the vendored spec or generated output drifts from the released asset; bumping +the pin itself is a manual edit to this file. From 08a41dff42f045cfa897cf85a238f4f4c8520a49 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Wed, 24 Jun 2026 16:32:42 +0200 Subject: [PATCH 6/7] fix(spec): bump pin to v0.1.0-rc.1 v0.1.0-rc.0 was a throwaway dry-run release and has been deleted from the platform repo, so spec-sync's fetch URL 404s. Repin to the published v0.1.0-rc.1 release. The vendored client.yaml is byte-identical to the rc.1 asset, so gen/ is unchanged. --- spec/SOURCE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/SOURCE.md b/spec/SOURCE.md index 998eac2..3af6878 100644 --- a/spec/SOURCE.md +++ b/spec/SOURCE.md @@ -7,13 +7,13 @@ it via `make generate` (`go generate ./...`). Do not hand-edit either file. | Field | Value | | ----------- | ------------------------------------ | | Source repo | https://github.com/lunogram/platform | -| Pinned tag | `v0.1.0-rc.0` | +| Pinned tag | `v0.1.0-rc.1` | | Spec asset | `client.yaml` | Release asset URL used by `spec-sync.yml` to re-fetch: ``` -https://github.com/lunogram/platform/releases/download/v0.1.0-rc.0/client.yaml +https://github.com/lunogram/platform/releases/download/v0.1.0-rc.1/client.yaml ``` ## About the pin From 2f9fa8aa3983aad2d4f524764a004630caffcbf3 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 25 Jun 2026 17:46:16 +0200 Subject: [PATCH 7/7] feat: add device, push and session endpoints with tests Bring the Go SDK to parity with the JS/Python SDKs on the full Client API surface, and expand test coverage: - RegisterDevice (POST /users/devices) - GetVapidPublicKey (GET /push/vapid) - CreateSession (POST /auth-methods/{id}/sessions) Tests: - conformance: DeviceRegistration, VapidPublicKey, CreateSessionRequest and SessionToken now round-trip against their generated spec types. - integration: RegisterDevice, GetVapidPublicKey, CreateSession (the last gated on LUNOGRAM_AUTH_METHOD_ID), plus the previously untested MarkUserInboxRead/Archived and MarkOrganizationInboxRead/Archived. README documents the new device/push/session methods. --- README.md | 37 ++++++++++++++++++++ conformance_test.go | 39 +++++++++++++++++++++ devices.go | 12 +++++++ devices_test.go | 41 ++++++++++++++++++++++ mark_inbox_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++++ models.go | 76 ++++++++++++++++++++++++++++++++++++++++ push.go | 15 ++++++++ push_test.go | 19 ++++++++++ sessions.go | 20 +++++++++++ sessions_test.go | 44 ++++++++++++++++++++++++ 10 files changed, 387 insertions(+) create mode 100644 devices.go create mode 100644 devices_test.go create mode 100644 mark_inbox_test.go create mode 100644 push.go create mode 100644 push_test.go create mode 100644 sessions.go create mode 100644 sessions_test.go diff --git a/README.md b/README.md index faad2e2..7037af0 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,43 @@ err = client.MarkOrganizationInboxArchived(ctx, []lunogram.OrganizationInboxMess }) ``` +## Devices and push + +Register a device's push subscription for a user, and fetch the project's VAPID +public key for Web Push: + +```go +os := lunogram.DeviceOSWeb +err := client.RegisterDevice(ctx, &lunogram.DeviceRegistration{ + Identifier: []lunogram.ExternalID{{ExternalID: "user_123"}}, + DeviceID: "user_123_web", + OS: &os, + Config: lunogram.DeviceConfig{ + Endpoint: lunogram.String("https://push.example.com/subscription"), + Keys: &lunogram.DeviceKeys{ + P256dh: "BKey-p256dh-value", + Auth: "auth-secret", + }, + }, +}) + +key, err := client.GetVapidPublicKey(ctx) +// key.PublicKey is the project's VAPID public key +``` + +## Sessions + +Mint a short-lived session token for an end user, server-side, so the client can +call the Client API directly. The session's permissions are defined by the +session auth method (policy) identified by `authMethodID`: + +```go +token, err := client.CreateSession(ctx, "auth-method-uuid", &lunogram.CreateSessionRequest{ + UserID: "user_123", +}) +// token.Token is a bearer token for the Client API; token.ExpiresAt is its expiry +``` + ## Scheduled resources Scheduled resources trigger journey entrance recalculation at a specific time or on a recurring interval. diff --git a/conformance_test.go b/conformance_test.go index 7843ea2..b4b7336 100644 --- a/conformance_test.go +++ b/conformance_test.go @@ -337,4 +337,43 @@ func TestFacadeTypesConformToSpec(t *testing.T) { Total: 10, }, &gen.InboxCount{}) }) + + // Device, push and session types. + t.Run("DeviceRegistration", func(t *testing.T) { + os := DeviceOSWeb + roundTrip(t, "DeviceRegistration", &DeviceRegistration{ + Identifier: []ExternalID{{ExternalID: "u1"}}, + DeviceID: "device_1", + Config: DeviceConfig{ + Token: String("fcm-token"), + Endpoint: String("https://push.example.com/sub"), + ExpirationTime: &at, + Keys: &DeviceKeys{P256dh: "p256", Auth: "auth"}, + }, + OS: &os, + OSVersion: String("17.0"), + Model: String("Pixel"), + AppVersion: String("1.2.3"), + Data: json.RawMessage(`{"channel":"beta"}`), + }, &gen.DeviceRegistration{}) + }) + + t.Run("VapidPublicKey", func(t *testing.T) { + roundTrip(t, "VapidPublicKey", &VapidPublicKey{ + PublicKey: "BPublicKeyValue", + }, &gen.VapidPublicKey{}) + }) + + t.Run("CreateSessionRequest", func(t *testing.T) { + roundTrip(t, "CreateSessionRequest", &CreateSessionRequest{ + UserID: "user_12345", + }, &gen.CreateSession{}) + }) + + t.Run("SessionToken", func(t *testing.T) { + roundTrip(t, "SessionToken", &SessionToken{ + Token: "signed.session.token", + ExpiresAt: at, + }, &gen.SessionToken{}) + }) } diff --git a/devices.go b/devices.go new file mode 100644 index 0000000..93bb235 --- /dev/null +++ b/devices.go @@ -0,0 +1,12 @@ +package lunogram + +import ( + "context" + "net/http" +) + +// RegisterDevice registers or updates a device's push subscription for a user. +// The user is identified by the registration's Identifier. +func (c *Client) RegisterDevice(ctx context.Context, req *DeviceRegistration) error { + return c.do(ctx, http.MethodPost, "/users/devices", req, nil) +} diff --git a/devices_test.go b/devices_test.go new file mode 100644 index 0000000..b0fb352 --- /dev/null +++ b/devices_test.go @@ -0,0 +1,41 @@ +package lunogram_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + lunogram "github.com/lunogram/go-sdk" +) + +func TestRegisterDevice(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + _, err := client.UpsertUser(ctx, &lunogram.UpsertUserRequest{ + Identifier: []lunogram.ExternalID{{ExternalID: "device_user_1"}}, + }) + require.NoError(t, err, "UpsertUser for device target should succeed") + + os := lunogram.DeviceOSWeb + err = client.RegisterDevice(ctx, &lunogram.DeviceRegistration{ + Identifier: []lunogram.ExternalID{{ExternalID: "device_user_1"}}, + DeviceID: "device_user_1_web", + OS: &os, + Config: lunogram.DeviceConfig{ + Endpoint: lunogram.String("https://push.example.com/subscription"), + Keys: &lunogram.DeviceKeys{ + P256dh: "BKey-p256dh-value", + Auth: "auth-secret", + }, + }, + }) + require.NoError(t, err, "RegisterDevice should succeed") + + t.Cleanup(func() { + _ = client.DeleteUser(ctx, &lunogram.DeleteUserRequest{ + Identifier: []lunogram.ExternalID{{ExternalID: "device_user_1"}}, + }) + }) +} diff --git a/mark_inbox_test.go b/mark_inbox_test.go new file mode 100644 index 0000000..ad43ec3 --- /dev/null +++ b/mark_inbox_test.go @@ -0,0 +1,84 @@ +package lunogram_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + lunogram "github.com/lunogram/go-sdk" +) + +// A syntactically valid message UUID. The mark read/archived endpoints accept +// the reference and process it asynchronously (202), so a well-formed id is +// enough to exercise the endpoint wiring without racing the async inbox write. +const markMessageID = "11111111-2222-4333-8444-555555555555" + +func TestUserInboxMarkReadArchived(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + _, err := client.UpsertUser(ctx, &lunogram.UpsertUserRequest{ + Identifier: []lunogram.ExternalID{{ExternalID: "inbox_mark_user"}}, + }) + require.NoError(t, err, "UpsertUser for inbox mark target should succeed") + + err = client.PostUserInboxMessages(ctx, []lunogram.InboxMessageCreate{ + { + Target: []lunogram.ExternalID{{ExternalID: "inbox_mark_user"}}, + Identifier: lunogram.ExternalID{ExternalID: "inbox_mark_msg_1"}, + Channel: lunogram.ChannelInbox, + Content: json.RawMessage(`{"title":"Hi"}`), + }, + }) + require.NoError(t, err, "PostUserInboxMessages should succeed") + + refs := []lunogram.InboxMessageRef{{ + Target: []lunogram.ExternalID{{ExternalID: "inbox_mark_user"}}, + MessageID: markMessageID, + }} + + require.NoError(t, client.MarkUserInboxRead(ctx, refs), "MarkUserInboxRead should succeed") + require.NoError(t, client.MarkUserInboxArchived(ctx, refs), "MarkUserInboxArchived should succeed") + + t.Cleanup(func() { + _ = client.DeleteUser(ctx, &lunogram.DeleteUserRequest{ + Identifier: []lunogram.ExternalID{{ExternalID: "inbox_mark_user"}}, + }) + }) +} + +func TestOrganizationInboxMarkReadArchived(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + _, err := client.UpsertOrganization(ctx, &lunogram.UpsertOrganizationRequest{ + Identifier: []lunogram.ExternalID{{ExternalID: "inbox_mark_org"}}, + }) + require.NoError(t, err, "UpsertOrganization for inbox mark target should succeed") + + err = client.PostOrganizationInboxMessages(ctx, []lunogram.OrganizationInboxMessageCreate{ + { + Target: []lunogram.ExternalID{{ExternalID: "inbox_mark_org"}}, + Identifier: lunogram.ExternalID{ExternalID: "inbox_mark_org_msg_1"}, + Channel: lunogram.ChannelInbox, + Content: json.RawMessage(`{"title":"Hi"}`), + }, + }) + require.NoError(t, err, "PostOrganizationInboxMessages should succeed") + + refs := []lunogram.OrganizationInboxMessageRef{{ + Target: []lunogram.ExternalID{{ExternalID: "inbox_mark_org"}}, + MessageID: markMessageID, + }} + + require.NoError(t, client.MarkOrganizationInboxRead(ctx, refs), "MarkOrganizationInboxRead should succeed") + require.NoError(t, client.MarkOrganizationInboxArchived(ctx, refs), "MarkOrganizationInboxArchived should succeed") + + t.Cleanup(func() { + _ = client.DeleteOrganization(ctx, &lunogram.DeleteOrganizationRequest{ + Identifier: []lunogram.ExternalID{{ExternalID: "inbox_mark_org"}}, + }) + }) +} diff --git a/models.go b/models.go index 972392b..af5edb0 100644 --- a/models.go +++ b/models.go @@ -472,3 +472,79 @@ type InboxCount struct { Unread int `json:"unread"` Total int `json:"total"` } + +// DeviceOS is the operating system a registered device runs on. +type DeviceOS string + +// Supported device operating systems. +const ( + DeviceOSWeb DeviceOS = "web" + DeviceOSIOS DeviceOS = "ios" + DeviceOSAndroid DeviceOS = "android" +) + +// DeviceKeys holds the Web Push subscription keys. +type DeviceKeys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` +} + +// DeviceConfig is the transport configuration for a device's push subscription. +type DeviceConfig struct { + // Token is the device token for FCM or APNs. + Token *string `json:"token,omitempty"` + + // Endpoint is the Web Push subscription endpoint URL. + Endpoint *string `json:"endpoint,omitempty"` + + // ExpirationTime is when the subscription expires, if known. + ExpirationTime *time.Time `json:"expiration_time,omitempty"` + + // Keys holds the Web Push subscription keys. + Keys *DeviceKeys `json:"keys,omitempty"` +} + +// DeviceRegistration registers or updates a device's push subscription for a user. +type DeviceRegistration struct { + // Identifier targets the user the device belongs to. + Identifier []ExternalID `json:"identifier"` + + // DeviceID is the caller's stable identifier for the device. + DeviceID string `json:"device_id"` + + // Config is the device's push transport configuration. + Config DeviceConfig `json:"config"` + + // OS is the device operating system (web, ios, android). + OS *DeviceOS `json:"os,omitempty"` + + // OSVersion is the device operating system version. + OSVersion *string `json:"os_version,omitempty"` + + // Model is the device model. + Model *string `json:"model,omitempty"` + + // AppVersion is the version of the app the device runs. + AppVersion *string `json:"app_version,omitempty"` + + // Data holds arbitrary device attributes. + Data json.RawMessage `json:"data,omitempty"` +} + +// VapidPublicKey is the project's VAPID public key for Web Push. +type VapidPublicKey struct { + PublicKey string `json:"public_key"` +} + +// CreateSessionRequest mints a session token for an end user. +type CreateSessionRequest struct { + // UserID is the end user's external identifier (the session subject). + UserID string `json:"user_id"` +} + +// SessionToken is a minted session token: a short-lived bearer token for the +// Client API whose permissions are defined by the session auth method (policy). +type SessionToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} diff --git a/push.go b/push.go new file mode 100644 index 0000000..e2735c6 --- /dev/null +++ b/push.go @@ -0,0 +1,15 @@ +package lunogram + +import ( + "context" + "net/http" +) + +// GetVapidPublicKey returns the project's VAPID public key for Web Push. +func (c *Client) GetVapidPublicKey(ctx context.Context) (*VapidPublicKey, error) { + var key VapidPublicKey + if err := c.do(ctx, http.MethodGet, "/push/vapid", nil, &key); err != nil { + return nil, err + } + return &key, nil +} diff --git a/push_test.go b/push_test.go new file mode 100644 index 0000000..3e38367 --- /dev/null +++ b/push_test.go @@ -0,0 +1,19 @@ +package lunogram_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetVapidPublicKey(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + key, err := client.GetVapidPublicKey(ctx) + require.NoError(t, err, "GetVapidPublicKey should succeed") + require.NotNil(t, key) + assert.NotEmpty(t, key.PublicKey, "VAPID public key should be set") +} diff --git a/sessions.go b/sessions.go new file mode 100644 index 0000000..e3aeab6 --- /dev/null +++ b/sessions.go @@ -0,0 +1,20 @@ +package lunogram + +import ( + "context" + "net/http" + "net/url" +) + +// CreateSession mints a short-lived session token for an end user, to be used +// from the client. It is called server-side with an API key; the session's +// permissions are defined by the session auth method (policy) identified by +// authMethodID. +func (c *Client) CreateSession(ctx context.Context, authMethodID string, req *CreateSessionRequest) (*SessionToken, error) { + var token SessionToken + path := "/auth-methods/" + url.PathEscape(authMethodID) + "/sessions" + if err := c.do(ctx, http.MethodPost, path, req, &token); err != nil { + return nil, err + } + return &token, nil +} diff --git a/sessions_test.go b/sessions_test.go new file mode 100644 index 0000000..fdfab6a --- /dev/null +++ b/sessions_test.go @@ -0,0 +1,44 @@ +package lunogram_test + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + lunogram "github.com/lunogram/go-sdk" +) + +// TestCreateSession exercises the session-minting endpoint. It needs a session +// auth method (policy) to mint against, supplied via LUNOGRAM_AUTH_METHOD_ID; +// the test skips when that is not set (in addition to the usual credential gate). +func TestCreateSession(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + authMethodID := os.Getenv("LUNOGRAM_AUTH_METHOD_ID") + if authMethodID == "" { + t.Skip("LUNOGRAM_AUTH_METHOD_ID is not set; skipping session test") + } + + _, err := client.UpsertUser(ctx, &lunogram.UpsertUserRequest{ + Identifier: []lunogram.ExternalID{{ExternalID: "session_user_1"}}, + }) + require.NoError(t, err, "UpsertUser for session subject should succeed") + + token, err := client.CreateSession(ctx, authMethodID, &lunogram.CreateSessionRequest{ + UserID: "session_user_1", + }) + require.NoError(t, err, "CreateSession should succeed") + require.NotNil(t, token) + assert.NotEmpty(t, token.Token, "session token should be set") + assert.False(t, token.ExpiresAt.IsZero(), "session token should have an expiry") + + t.Cleanup(func() { + _ = client.DeleteUser(ctx, &lunogram.DeleteUserRequest{ + Identifier: []lunogram.ExternalID{{ExternalID: "session_user_1"}}, + }) + }) +}