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
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ update-openapi: patch-oapi-templates ## Update OpenAPI spec
$(MAKE) -C api/spec generate
go generate ./api/... ./openmeter/.../notificationevents

.PHONY: update-notification-definitions-sum
update-notification-definitions-sum: ## Rewrite notification/v4/definitions/definitions.sum; only for definitions that were never released
$(call print-target)
go run ./openmeter/notification/v4/definitions/internal/sumgen openmeter/notification/v4/definitions/definitions.sum

.PHONY: generate-javascript-sdk
generate-javascript-sdk: ## Generate JavaScript SDK
$(call print-target)
Expand Down
2 changes: 1 addition & 1 deletion api/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ The AIP package defines v3 metering and billing APIs in line with [Kong’s AIP

The producing domain owns the payload, and for rule-produced events the Rule configuration, of each Notification Event type. It declares them in a `<Domain>.NotificationEvents` namespace, mapped to a Go package in `packages/aip/src/notification-events.tsp`. `make update-openapi` generates the Go structs, JSON Schemas, event type values and event type definitions into `openmeter/<domain>/notificationevents`, the list of every definition into `openmeter/notification/v4/definitions`, and the events-only OpenAPI spec into `api/notificationevents/<domain>/`.

- A released contract is immutable. Change it by adding the next version (`v2.<name>`, `...V2Payload`) next to it, never by editing it in place. Nested types follow the same rule: a breaking change needs a new type. Reviewers enforce this; generation does not.
- A released contract is immutable. Change it by adding the next version (`v2.<name>`, `...V2Payload`) next to it, never by editing it in place. Nested types follow the same rule: a breaking change needs a new type. A test fails when a generated definition no longer matches [`definitions.sum`](../../openmeter/notification/v4/definitions/definitions.sum); rewrite the file with `make update-notification-definitions-sum` only for a definition that was never released.
- Payloads are dedicated models. They may use `Shared` types but must not reuse API resource models, so that API changes cannot change a contract.
- [`packages/typespec-notification-events`](packages/typespec-notification-events) enforces the naming and `@id` conventions at compile time.
25 changes: 20 additions & 5 deletions openmeter/notification/v4/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Package `v4` holds the Notification vNext model. It is built next to the
existing [Notifications](../README.md) domain, which keeps serving V1. This
README covers Event types and the Event Registry. Providers are documented in
[Svix](providers/svix/README.md) and [no-op](providers/noop/README.md).
[Svix](provider/svix/README.md) and [no-op](provider/noop/README.md).

Producing domains import `v4` for `EventType`, so `v4` must not import
Notification or any domain package; doing so would create an import cycle.
Expand Down Expand Up @@ -66,8 +66,22 @@ An `EventTypeDefinition` binds an Event type to its contracts:
checked by the registry. Callers with stricter needs, such as requiring a
Rule-capable type, add their own checks through `ValidateWith`.

Definitions are equal when their identity, schema bytes, Rule presence, and
replacement match. Schemas that differ only in formatting are not equal.
Definitions are equal when their identity, schemas, Rule presence, and
replacement match. Schemas are compared in canonical form, so formatting and
object key order do not matter, but array order and number text do.

A released definition must not change in place; a changed contract is a new
Event type version. `definitions.Fingerprint` hashes what must stay fixed: the
Event type and its schemas in canonical form, but not `DeprecatedBy`, which is
set on released definitions. [`definitions.sum`](definitions/definitions.sum)
records the fingerprint of every definition and, in a separate column, its
replacement. The file is embedded, and `definitions.Verify` fails a test when
either no longer matches, so removing or changing a deprecation is caught too.
Only the author can tell whether a definition was released, so fixing an
unreleased one means rewriting the file with
`make update-notification-definitions-sum`, and the change shows up in review.
`make generate` deliberately does not rewrite it, so routine regeneration
cannot silently accept a changed contract.

A definition is pure contract data. Domain validation of Rule configurations
needs domain services, such as checking that referenced features exist, so it
Expand Down Expand Up @@ -139,6 +153,7 @@ registers. Channel Event type filters are meant to use the same grammar.
- The registry is process-local. Each replica knows only the types built into
its binary, so during a rolling deployment replicas can disagree about which
types exist.
- Released contracts are immutable by review rule only. No contract ledger or
CI check detects an in-place change yet.
- `definitions.sum` catches an in-place change in CI, but whether the change
is safe is decided in review. Nothing checks what a given database has
already seen.
- Listing is not paginated; the list is small and held in memory.
4 changes: 4 additions & 0 deletions openmeter/notification/v4/definitions/definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@
// TypeSpec contracts; run `make gen-api` after adding or changing one. It is a
// separate package because the domain packages import v4, so v4 cannot import
// them.
//
// definitions.sum records the fingerprint and replacement of every definition,
// so that changing a released definition fails Verify instead of slipping
// through review.
package definitions
2 changes: 2 additions & 0 deletions openmeter/notification/v4/definitions/definitions.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
v1.billing.invoice.created sha256:f5969a05344f5661a3dcb489859bea2e256c15b5195be3cfd8d643e263601754
v1.entitlement.balance_threshold.triggered sha256:d9c36350fac8cb69aa8bb928f51baa33719317f8a96e6e4fe24b6641b6ecd465
8 changes: 8 additions & 0 deletions openmeter/notification/v4/definitions/definitions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,11 @@ func TestDefinitionsRegister(t *testing.T) {
require.NoError(t, err)
require.Len(t, registry.EventTypes(nil), len(defs))
}

// definitions.sum records the fingerprint and replacement of every
// definition, so changing one in place shows up in review.
func TestDefinitionsSum(t *testing.T) {
require.NoError(t, definitions.Verify(),
"An Event type definition or its deprecation changed. A released definition must not change: add a new version instead. "+
"If the changed definitions were never released, run: make update-notification-definitions-sum")
}
29 changes: 29 additions & 0 deletions openmeter/notification/v4/definitions/internal/sumgen/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Command sumgen rewrites definitions.sum from the current definitions.
package main

import (
"bytes"
"fmt"
"os"

"github.com/openmeterio/openmeter/openmeter/notification/v4/definitions"
)

func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: sumgen <path to definitions.sum>")
os.Exit(2)
}

// Generate into memory first, so a failure leaves the file untouched.
var out bytes.Buffer
if err := definitions.Generate(&out); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

if err := os.WriteFile(os.Args[1], out.Bytes(), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
112 changes: 112 additions & 0 deletions openmeter/notification/v4/definitions/sum.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package definitions

import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"slices"

v4 "github.com/openmeterio/openmeter/openmeter/notification/v4"
"github.com/openmeterio/openmeter/pkg/jsonx"
)

// sum is the committed definitions.sum. Regenerate it with
// `make update-notification-definitions-sum`, never as part of `make generate`:
// rewriting it must be a deliberate decision that a changed definition was
// never released.
//
//go:embed definitions.sum
var sum []byte

// Generate writes one line per definition, sorted by Event type: the Event type,
// its fingerprint and, if deprecated, its replacement. The replacement is kept
// out of the fingerprint but recorded here, so setting, changing or removing a
// deprecation also shows up in review.
func Generate(w io.Writer) error {
return generate(w, All())
}

// Verify checks that the committed definitions.sum matches the current
// definitions.
func Verify() error {
return verify(sum, All())
}

func generate(w io.Writer, defs []v4.EventTypeDefinition) error {
defs = slices.Clone(defs)
slices.SortFunc(defs, func(a, b v4.EventTypeDefinition) int {
return a.EventType.Compare(b.EventType)
})

for _, def := range defs {
fingerprint, err := Fingerprint(def)
if err != nil {
return fmt.Errorf("event type %s: %w", def.EventType, err)
}

line := fmt.Sprintf("%s %s", def.EventType, fingerprint)
if def.DeprecatedBy != nil {
line += fmt.Sprintf(" deprecated_by=%s", def.DeprecatedBy)
}

if _, err := fmt.Fprintln(w, line); err != nil {
return err
}
}

return nil
}

func verify(committed []byte, defs []v4.EventTypeDefinition) error {
var want bytes.Buffer
if err := generate(&want, defs); err != nil {
return err
}

if bytes.Equal(want.Bytes(), committed) {
return nil
}

return fmt.Errorf("definitions.sum does not match the current definitions\ncommitted:\n%s\ncurrent:\n%s", committed, want.Bytes())
}

// Fingerprint identifies what must not change once the definition is
// released: the Event type, the payload schema and the Rule configuration
// schema, if any. Schemas are hashed in canonical form, so formatting and object
// key order do not change it. DeprecatedBy is left out because it is set on
// released definitions.
func Fingerprint(d v4.EventTypeDefinition) (string, error) {
payload, err := jsonx.Canonical(d.PayloadJSONSchema)
if err != nil {
return "", fmt.Errorf("payload JSON schema: %w", err)
}

ruleConfig := json.RawMessage("null")
if d.Rule != nil {
ruleConfig, err = jsonx.Canonical(d.Rule.JSONSchema)
if err != nil {
return "", fmt.Errorf("rule config JSON schema: %w", err)
}
}

doc, err := json.Marshal(struct {
EventType string `json:"event_type"`
Payload json.RawMessage `json:"payload"`
RuleConfig json.RawMessage `json:"rule_config"`
}{
EventType: d.EventType.String(),
Payload: payload,
RuleConfig: ruleConfig,
})
if err != nil {
return "", err
}

sum := sha256.Sum256(doc)

return "sha256:" + hex.EncodeToString(sum[:]), nil
}
154 changes: 154 additions & 0 deletions openmeter/notification/v4/definitions/sum_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package definitions

import (
"bytes"
"encoding/json"
"testing"

"github.com/samber/lo"
"github.com/stretchr/testify/require"

v4 "github.com/openmeterio/openmeter/openmeter/notification/v4"
)

func TestFingerprint(t *testing.T) {
const (
payloadSchema = `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}`
ruleSchema = `{"type":"object","properties":{"a":{"enum":["x","y"]}}}`
)

def := func(payload string, ruleConfig *string) v4.EventTypeDefinition {
d := v4.EventTypeDefinition{
EventType: v4.EventType{Name: "billing.invoice.created", Version: 1},
PayloadJSONSchema: json.RawMessage(payload),
}
if ruleConfig != nil {
d.Rule = &v4.RuleConfigDefinition{JSONSchema: json.RawMessage(*ruleConfig)}
}

return d
}

fingerprint := func(d v4.EventTypeDefinition) string {
t.Helper()

f, err := Fingerprint(d)
require.NoError(t, err)

return f
}

base := fingerprint(def(payloadSchema, lo.ToPtr(ruleSchema)))
require.Regexp(t, `^sha256:[0-9a-f]{64}$`, base)

// Schemas are hashed in canonical form, so formatting and key order do not
// change the fingerprint.
require.Equal(t, base, fingerprint(def(
`{"type": "object", "$schema": "https://json-schema.org/draft/2020-12/schema"}`,
lo.ToPtr(`{"properties": {"a": {"enum": ["x", "y"]}}, "type": "object"}`),
)))

// A replacement is set on released definitions, so it is left out.
deprecated := def(payloadSchema, lo.ToPtr(ruleSchema))
deprecated.DeprecatedBy = &v4.EventType{Name: "billing.invoice.created", Version: 2}
require.Equal(t, base, fingerprint(deprecated))

otherType := def(payloadSchema, lo.ToPtr(ruleSchema))
otherType.EventType.Version = 2

changed := []struct {
name string
def v4.EventTypeDefinition
}{
{"event type", otherType},
{"payload", def(`{"type":"object","required":["a"]}`, lo.ToPtr(ruleSchema))},
{"no rule", def(payloadSchema, nil)},
{"rule config", def(payloadSchema, lo.ToPtr(`{"type":"object","properties":{"a":{"enum":["x","z"]}}}`))},
}

seen := map[string]string{base: "base"}
for _, tt := range changed {
t.Run(tt.name, func(t *testing.T) {
f := fingerprint(tt.def)
require.NotContains(t, seen, f, "same fingerprint as %s", seen[f])
seen[f] = tt.name
})
}

_, err := Fingerprint(def(`{`, nil))
require.ErrorContains(t, err, "payload JSON schema")

_, err = Fingerprint(def(payloadSchema, lo.ToPtr(`{`)))
require.ErrorContains(t, err, "rule config JSON schema")
}

func TestGenerate(t *testing.T) {
invoiceV1 := v4.EventType{Name: "billing.invoice.created", Version: 1}
invoiceV2 := v4.EventType{Name: "billing.invoice.created", Version: 2}

defs := []v4.EventTypeDefinition{
{EventType: invoiceV2, PayloadJSONSchema: json.RawMessage(`{"type":"object"}`)},
{EventType: invoiceV1, PayloadJSONSchema: json.RawMessage(`{"type":"object"}`), DeprecatedBy: &invoiceV2},
}

fingerprint := func(def v4.EventTypeDefinition) string {
t.Helper()

f, err := Fingerprint(def)
require.NoError(t, err)

return f
}

var out bytes.Buffer
require.NoError(t, generate(&out, defs))

// Lines are sorted by Event type, and only the deprecated definition gets
// the replacement column.
require.Equal(t,
"v1.billing.invoice.created "+fingerprint(defs[1])+" deprecated_by=v2.billing.invoice.created\n"+
"v2.billing.invoice.created "+fingerprint(defs[0])+"\n",
out.String(),
)
require.Equal(t, invoiceV2, defs[0].EventType, "the caller's slice is not reordered")

invalid := []v4.EventTypeDefinition{{EventType: invoiceV1, PayloadJSONSchema: json.RawMessage(`{`)}}
require.ErrorContains(t, generate(&bytes.Buffer{}, invalid), "v1.billing.invoice.created")
}

func TestVerify(t *testing.T) {
invoiceV1 := v4.EventType{Name: "billing.invoice.created", Version: 1}
invoiceV2 := v4.EventType{Name: "billing.invoice.created", Version: 2}
invoiceV3 := v4.EventType{Name: "billing.invoice.created", Version: 3}

def := func(payload string, deprecatedBy *v4.EventType) []v4.EventTypeDefinition {
return []v4.EventTypeDefinition{
{EventType: invoiceV1, PayloadJSONSchema: json.RawMessage(payload), DeprecatedBy: deprecatedBy},
{EventType: invoiceV2, PayloadJSONSchema: json.RawMessage(`{"type":"object"}`)},
{EventType: invoiceV3, PayloadJSONSchema: json.RawMessage(`{"type":"object"}`)},
}
}

var committed bytes.Buffer
require.NoError(t, generate(&committed, def(`{"type":"object"}`, &invoiceV2)))

require.NoError(t, verify(committed.Bytes(), def(`{"type":"object"}`, &invoiceV2)))

changed := []struct {
name string
defs []v4.EventTypeDefinition
}{
{"payload changed", def(`{"type":"object","required":["a"]}`, &invoiceV2)},
{"deprecation removed", def(`{"type":"object"}`, nil)},
{"replacement changed", def(`{"type":"object"}`, &invoiceV3)},
{"definition removed", def(`{"type":"object"}`, &invoiceV2)[:2]},
}

for _, tt := range changed {
t.Run(tt.name, func(t *testing.T) {
require.ErrorContains(t, verify(committed.Bytes(), tt.defs), "does not match")
})
}

require.Error(t, verify(committed.Bytes(), def(`{`, &invoiceV2)))
}
Loading
Loading