diff --git a/README.md b/README.md index e6ca377..6a030e3 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,11 @@ func main() { } ``` -The base URL includes the API's path prefix. Every Kaiten deployment serves its own -OpenAPI document at `/api/docs`; the [Kaiten documentation](https://docs.kaiten.sh) -describes the resources and fields. +The base URL includes the API's path prefix: `https://app.kaiten.sh/api` on Kaiten +Cloud, `http://localhost:6060/api` on a local Community Edition stack. The API serves +its own OpenAPI document at `/api/openapi.json` and a reference rendered from it at +`/api/docs`; the [Kaiten documentation](https://docs.kaiten.sh) describes the resources +and fields. ## Two APIs, two clients @@ -76,10 +78,14 @@ Kaiten exposes two contracts on two listeners, and this package mirrors that spl | | `sdk.NewClient` | `sdk.NewPlatformClient` | | -------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Contract | Core API | Platform API | -| Default port | 3000 | 3001 | +| Local stack | `http://localhost:6060/api` | `http://localhost:6001/api` | | Credential | organization-scoped token (`ksh_...`) | platform credential (`ksm_...`) | | Resources | customers, instances, licenses, entitlements, feature flags, components | organizations, users, organization tokens, connector registry | +The Platform API is an internal listener, for the operator of a self-hosted Kaiten: it +is on port 3001 unless configured otherwise, and the local Community Edition stack +publishes it on 6001. + Each listener refuses the other's credential, so a process that needs both builds both, against different base URLs. `Option` values are shared between the two constructors. @@ -122,19 +128,21 @@ operations below. ```go customer, err := client.Customers.Create(ctx, sdk.CustomerInput{ - Name: "Acme Corp", + Name: "Sakura Tokyo", }) if err != nil { return err } +slug := "sakura-tokyo-prod" instance, err := client.Instances.Create(ctx, sdk.InstanceInput{ - Name: "acme-production", + Name: "Sakura Tokyo Production", + Slug: &slug, CustomerID: *customer.Id, LicenseID: licenseID, StartLicenseDate: time.Now(), EndLicenseDate: time.Now().AddDate(1, 0, 0), - Metadata: map[string]any{"region": "eu-west-1"}, + Metadata: map[string]any{"environment": "production"}, }) if err != nil { return err @@ -158,16 +166,16 @@ A license is one version of a family; `LicenseFamilies` reads the family with it current version, `Licenses` writes individual versions. ```go -// Grant 25 seats to the license, then make it available to instances. -seats, err := sdk.NumberLicenseValue(25) +// Grant 10 delivery drivers to the draft license, then make it available to instances. +drivers, err := sdk.NumberLicenseValue(10) if err != nil { return err } -if err := client.Licenses.AssociateEntitlement(ctx, "team-v1", "seats", seats); err != nil { +if err := client.Licenses.AssociateEntitlement(ctx, "premium-v3", "delivery-drivers", drivers); err != nil { return err } -license, err := client.Licenses.Publish(ctx, "team-v1") +license, err := client.Licenses.Publish(ctx, "premium-v3") if err != nil { return err } @@ -188,7 +196,7 @@ usage past its value: slug, number, sum := "monthly-orders", sdk.EntitlementType("NUMBER"), sdk.EntitlementAggregationMethod("SUM") month, calendar, warning := sdk.EntitlementResetPeriodMonth, sdk.EntitlementResetAnchorCalendar, int32(80) if _, err := client.Entitlements.Create(ctx, sdk.EntitlementInput{ - Name: "Monthly orders", + Name: "Monthly Orders", Slug: &slug, Type: &number, AggregationMethod: &sum, @@ -204,7 +212,7 @@ orders, err := sdk.NumberLicenseValue(1000) if err != nil { return err } -err = client.Licenses.AssociateEntitlement(ctx, "team-v1", slug, orders, sdk.WithOveragePercent(20)) +err = client.Licenses.AssociateEntitlement(ctx, "premium-v2", slug, orders, sdk.WithOveragePercent(20)) ``` `Entitlements.Update` and `Licenses.UpdateEntitlement` replace rather than patch. A @@ -217,14 +225,14 @@ stay. ```go // Report a delta against a metered entitlement. -err := client.Instances.ReportUsage(ctx, "acme-production", "api-calls", 1) +err := client.Instances.ReportUsage(ctx, "sakura-tokyo-prod", "monthly-orders", 1) if errors.Is(err, sdk.ErrThresholdExceeded) { - // The instance has reached its limit for this entitlement. + // Refused: usage would pass what the license grants, allowance included. } // Or set the absolute value and get the resulting metric back. -usage, err := client.Instances.ReportEntitlementUsageMetric(ctx, "acme-production", "seats", - sdk.UsageReportInput{Value: 18, Behavior: sdk.Set}, +usage, err := client.Instances.ReportEntitlementUsageMetric(ctx, "sakura-tokyo-prod", "delivery-drivers", + sdk.UsageReportInput{Value: 8, Behavior: sdk.Set}, ) ``` @@ -318,33 +326,36 @@ connector, err := platform.Connectors.Register(ctx, sdk.RegisterConnectorInput{ Every list method returns the whole collection: the SDK walks the API's cursor pages itself, and a caller never sees a cursor. -The one exception is `Instances.ListAuditTrails`, whose collection grows without -bound. `AuditTrailsOptions.Limit` caps how many entries are fetched, and +`Instances.ListAuditTrails` is the one list that takes a ceiling, because its +collection grows without bound. `AuditTrailsOptions.Limit` caps how many entries are +fetched -- without one the whole trail is walked, like any other list -- and `EventName`, `After` and `Before` filter them. ```go limit := int32(100) -trails, err := client.Instances.ListAuditTrails(ctx, "acme-production", &sdk.AuditTrailsOptions{ - EventName: "instance.updated", +trails, err := client.Instances.ListAuditTrails(ctx, "sakura-tokyo-prod", &sdk.AuditTrailsOptions{ + EventName: "INSTANCE_UPDATED", After: &since, Limit: &limit, }) ``` -`InstancesListOptions.IncludeDeleted` and `AuditTrailsOptions.Offset` are still sent -but ignored by current Kaiten versions; their doc comments say why they remain. +Two options remain only so that callers still compile, and their doc comments say +why: `InstancesListOptions.IncludeDeleted` is still sent, and ignored by current Kaiten +versions; `AuditTrailsOptions.Offset` is deprecated and never sent, since the API +paginates by cursor only. ## Error handling Every non-2xx response is returned as an `*sdk.Error`. ```go -instance, err := client.Instances.Get(ctx, "acme-production") +instance, err := client.Instances.Get(ctx, "sakura-tokyo-prod") if err != nil { var apiErr *sdk.Error if errors.As(err, &apiErr) { switch { - case apiErr.Code == "Instance.NotFound": + case apiErr.Code == "GetInstance.NotFound": // Branch on Code: it is stable and distinguishes, for example, // the two different 404s a family endpoint can answer. case apiErr.StatusCode == http.StatusForbidden: @@ -363,7 +374,7 @@ if err != nil { | Field | Contents | | ------------ | --------------------------------------------------------------------- | | `StatusCode` | HTTP status code. | -| `Code` | Stable, machine-readable code such as `License.NotFound`, when given. | +| `Code` | Stable, machine-readable code such as `GetLicense.NotFound`, if any. | | `Problem` | The decoded problem-details body (`title`, `detail`, `errors`, ...). | | `ErrorID` | Correlation id for the server-side log entry, when given. | | `Body` | The raw response body. | diff --git a/helpers.go b/helpers.go index 9217079..6cd437e 100644 --- a/helpers.go +++ b/helpers.go @@ -30,7 +30,7 @@ type Error struct { Problem *ErrorModel Body []byte - // Code is the stable, machine-readable error code (e.g. "License.NotFound"), + // Code is the stable, machine-readable error code (e.g. "GetLicense.NotFound"), // when the API supplied one. Branch on this rather than on Problem.Title or the // status code alone: a 409 from an entitlement limit and a 409 from a slug // collision are the same status but not the same thing, and the license family diff --git a/inputs.go b/inputs.go index b727531..fd2070e 100644 --- a/inputs.go +++ b/inputs.go @@ -42,23 +42,30 @@ type AuditTrailsOptions struct { After *time.Time Before *time.Time - // Limit caps how many entries are returned. Unlike every other list in - // this package, audit trails are not walked to completion: the collection - // grows without bound, so "all of them" is not a useful default and a - // caller's ceiling is honoured as one. + // Limit caps how many entries are returned. Audit trails are the one list + // in this package that takes a ceiling: the collection grows without + // bound, so a caller's ceiling is honoured as one. Nil or zero sets none, + // and the whole trail is then walked, like every other list. Limit *int32 - // Offset is dead. Audit trails moved from offset/limit to cursor - // pagination, so the Core API no longer declares this parameter and - // ignores it -- an offset that used to skip entries now returns the newest + // Offset is dead and never sent. Audit trails moved from offset/limit to + // cursor pagination, so the Core API no longer declares this parameter and + // would ignore it -- an offset that used to skip entries now returns the newest // ones. Left in place so callers still compile. Use Limit instead: it // walks as many pages as it takes to reach the count asked for, which // covers what offset was reached for in practice, and only resuming from // where a previous call stopped is no longer expressible. + // + // Deprecated: Offset has no effect. Use Limit. Offset *int32 } // ComponentInput is the input for creating or updating a component. +// +// PreviousComponentID names the component this one is the next version of, and is +// create-only. Update never sends it: the link is set once, at creation, and the API +// refuses an update that carries one, whatever its value, with a 422, +// UpdateComponent.PreviousComponentNotSettable. type ComponentInput struct { Name string Version string @@ -233,7 +240,7 @@ type InstanceInput struct { // bodies are not either: // // - Version is read-only. A family's first version is 1 and the server assigns the -// next number from there, so the create body declares no such field at all. +// next number from there, so Create never sends it and a value set there is lost. // Update sends it only as an echo of what was read, since a version's slug is // built from its number. // - Slug is chosen on create and never changed. Update sends it for the API to check @@ -254,7 +261,9 @@ type LicenseInput struct { Type LicenseType // Version is the license's version within its family, assigned by the server - // and never changed. Create ignores it. Update sends it, and the API accepts it + // and never changed. It cannot be chosen. Create never sends it, so a value set + // there is dropped without an error: the API would refuse it with a 422, + // CreateLicense.VersionNotSettable. Update sends it, and the API accepts it // empty or equal to the stored number -- so a License read back can be written // back as it is -- and refuses any other value with a 422, // UpdateLicense.VersionNotSettable. @@ -453,7 +462,7 @@ func (in ComponentInput) createPayload() componentCreatePayload { } // componentUpdatePayload drops PreviousComponentID: the predecessor link is established -// when a component is created and UpdateComponentBody does not publish it. +// when a component is created, and the update refuses the field rather than ignore it. type componentUpdatePayload struct { Name string `json:"name"` Version string `json:"version"` diff --git a/pagination_test.go b/pagination_test.go index 52db0d1..24bc52d 100644 --- a/pagination_test.go +++ b/pagination_test.go @@ -8,8 +8,11 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/http" + "slices" "testing" + "time" ) // pagedRows answers with the envelope Core actually sends, cutting rows into pages of @@ -179,6 +182,47 @@ func TestListAuditTrailsHonoursLimit(t *testing.T) { } } +// TestListAuditTrailsSendsOnlyWhatTheEndpointDeclares pins what AuditTrailsOptions +// promises beyond its Limit. Without one the trail is walked to its end, like +// any other list. And Offset never reaches the wire: the endpoint paginates by +// cursor and declares no such parameter, so the query carries the three filters +// and the paging pair the walk sets, under the endpoint's own names. +func TestListAuditTrailsSendsOnlyWhatTheEndpointDeclares(t *testing.T) { + t.Parallel() + + client, sent := givenClient(t, pagedRows(flagRows(300))) + + since := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC) + until := since.AddDate(0, 1, 0) + offset := int32(50) + entries, err := client.Instances.ListAuditTrails(t.Context(), "instance-slug", &AuditTrailsOptions{ + EventName: "INSTANCE_CREATED", + After: &since, + Before: &until, + Offset: &offset, + }) + if err != nil { + t.Fatalf("ListAuditTrails() error = %v", err) + } + + if len(entries) != 300 { + t.Fatalf("len(entries) = %d, want 300", len(entries)) + } + + want := [][]string{ + {"after", "before", "event_name", "limit"}, + {"after", "before", "cursor", "event_name", "limit"}, + } + if sent.count() != len(want) { + t.Fatalf("requests = %d, want %d", sent.count(), len(want)) + } + for i, keys := range want { + if got := slices.Sorted(maps.Keys(sent.at(t, i).query)); !slices.Equal(got, keys) { + t.Errorf("request %d query keys = %v, want %v", i, got, keys) + } + } +} + // TestListPathsAndSubresourcePaths pins the paths the hand-built requests // produce. Nothing but this test checks them, and a wrong path is a 404 at // runtime rather than a compile error.