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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 37 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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},
)
```

Expand Down Expand Up @@ -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:
Expand All @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 19 additions & 10 deletions inputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down
44 changes: 44 additions & 0 deletions pagination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading