diff --git a/Makefile b/Makefile index 49b9ba27e3..edeed7f06b 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ patch-oapi-templates: ## Patch oapi-codegen chi-middleware template with custom update-openapi: patch-oapi-templates ## Update OpenAPI spec $(call print-target) $(MAKE) -C api/spec generate - go generate ./api/... + go generate ./api/... ./openmeter/.../notificationevents .PHONY: generate-javascript-sdk generate-javascript-sdk: ## Generate JavaScript SDK diff --git a/api/notificationevents/billing/openapi.yaml b/api/notificationevents/billing/openapi.yaml new file mode 100644 index 0000000000..18eaaa71be --- /dev/null +++ b/api/notificationevents/billing/openapi.yaml @@ -0,0 +1,63 @@ +openapi: 3.0.0 +info: + title: Billing.NotificationEvents + description: Notification Event contracts owned by the billing domain. + version: 0.0.0 +tags: [] +paths: {} +components: + schemas: + BillingInvoiceCreatedV1Payload: + type: object + required: + - invoice_id + - customer_id + - status + - currency + - total + properties: + invoice_id: + allOf: + - $ref: "#/components/schemas/ULID" + description: Invoice that was created. + customer_id: + allOf: + - $ref: "#/components/schemas/ULID" + description: Customer the invoice belongs to. + status: + type: string + description: Invoice status at creation time. + currency: + allOf: + - $ref: "#/components/schemas/CurrencyCode" + description: Invoice currency. + total: + allOf: + - $ref: "#/components/schemas/Numeric" + description: Invoice total at creation time. + additionalProperties: false + description: |- + Payload of `v1.billing.invoice.created`. + + The payload identifies the invoice and carries its state at creation time; + consumers fetch the full invoice through the API when they need it. + CurrencyCode: + type: string + minLength: 3 + maxLength: 3 + pattern: ^[A-Z]{3}$ + description: |- + Three-letter [ISO4217](https://www.iso.org/iso-4217-currency-codes.html) + currency code. Custom three-letter currency codes are also supported for + convenience. + example: USD + Numeric: + type: string + pattern: ^\-?[0-9]+(\.[0-9]+)?$ + description: Numeric represents an arbitrary precision number. + ULID: + type: string + pattern: ^[0-7][0-9A-HJKMNP-TV-Z]{25}$ + description: ULID (Universally Unique Lexicographically Sortable Identifier). + title: ULID + example: 01G65Z755AFWAKHE12NY0CQ9FH diff --git a/api/notificationevents/entitlement/openapi.yaml b/api/notificationevents/entitlement/openapi.yaml new file mode 100644 index 0000000000..87953f9dc9 --- /dev/null +++ b/api/notificationevents/entitlement/openapi.yaml @@ -0,0 +1,94 @@ +openapi: 3.0.0 +info: + title: Entitlements.NotificationEvents + description: Notification Event contracts owned by the entitlements domain. + version: 0.0.0 +tags: [] +paths: {} +components: + schemas: + EntitlementBalanceThreshold: + type: object + required: + - type + - value + properties: + type: + allOf: + - $ref: "#/components/schemas/EntitlementBalanceThresholdType" + description: Condition kind of the threshold. + value: + type: number + format: double + description: Threshold value, interpreted according to the threshold type. + additionalProperties: false + description: A single entitlement balance threshold. + EntitlementBalanceThresholdTriggeredV1Payload: + type: object + required: + - customer_id + - entitlement_id + - feature_key + - current_balance + - threshold + properties: + customer_id: + allOf: + - $ref: "#/components/schemas/ULID" + description: Customer owning the entitlement. + entitlement_id: + allOf: + - $ref: "#/components/schemas/ULID" + description: Entitlement whose balance crossed the threshold. + feature_key: + allOf: + - $ref: "#/components/schemas/ResourceKey" + description: Feature of the entitlement. + current_balance: + type: number + format: double + description: Balance at evaluation time. + threshold: + allOf: + - $ref: "#/components/schemas/EntitlementBalanceThreshold" + description: Threshold that was crossed. + additionalProperties: false + description: Payload of `v1.entitlement.balance_threshold.triggered`. + EntitlementBalanceThresholdTriggeredV1RuleConfig: + type: object + required: + - thresholds + properties: + thresholds: + type: array + items: + $ref: "#/components/schemas/EntitlementBalanceThreshold" + minItems: 1 + description: Thresholds evaluated against the entitlement balance. + features: + type: array + items: + $ref: "#/components/schemas/ResourceKey" + description: Feature keys the Rule is limited to. All features when omitted. + additionalProperties: false + description: Rule configuration of `v1.entitlement.balance_threshold.triggered`. + EntitlementBalanceThresholdType: + type: string + enum: + - balance_at_or_below + - usage_percentage_at_or_above + description: Condition kind of an entitlement balance threshold. + ResourceKey: + type: string + minLength: 1 + maxLength: 64 + pattern: ^[a-z0-9]+(?:_[a-z0-9]+)*$ + description: A key is a unique string that is used to identify a resource. + title: Resource Key + example: resource_key + ULID: + type: string + pattern: ^[0-7][0-9A-HJKMNP-TV-Z]{25}$ + description: ULID (Universally Unique Lexicographically Sortable Identifier). + title: ULID + example: 01G65Z755AFWAKHE12NY0CQ9FH diff --git a/api/spec/Makefile b/api/spec/Makefile index ae4ec74067..3eb6a46f24 100644 --- a/api/spec/Makefile +++ b/api/spec/Makefile @@ -34,6 +34,7 @@ test: ## Run AIP TypeScript SDK and emitter tests pnpm --filter @openmeter/api-spec-aip run test pnpm run test:sdk:coverage pnpm --filter @openmeter/typespec-typescript run check + pnpm --filter @openmeter/typespec-notification-events run test .PHONY: publish-aip-sdk publish-aip-sdk: ## Publish the AIP TypeScript SDK (@openmeter/client) to npm diff --git a/api/spec/README.md b/api/spec/README.md index 469e54abf3..5d987eb76e 100644 --- a/api/spec/README.md +++ b/api/spec/README.md @@ -23,3 +23,13 @@ The AIP package defines v3 metering and billing APIs in line with [Kong’s AIP - **OpenMeter** (`openmeter.tsp`): OpenMeter v3 API. - **Konnect** (`konnect.tsp`): Konnect metering & billing API, same surface with Konnect-specific auth and servers. + +--- + +## Notification Event contracts (`packages/aip/src//notification-events/`) + +The producing domain owns the payload, and for rule-produced events the Rule configuration, of each Notification Event type. It declares them in a `.NotificationEvents` namespace, mapped to a Go package in `packages/aip/src/notification-events.tsp`. `make update-openapi` generates the Go structs, JSON Schemas and event type values into `openmeter//notificationevents`, and the events-only OpenAPI spec into `api/notificationevents//`. + +- A released contract is immutable. Change it by adding the next version (`v2.`, `...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. +- 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. diff --git a/api/spec/packages/aip/oapi-codegen.notification-events.yaml b/api/spec/packages/aip/oapi-codegen.notification-events.yaml new file mode 100644 index 0000000000..b8628a28f9 --- /dev/null +++ b/api/spec/packages/aip/oapi-codegen.notification-events.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json +# Shared oapi-codegen configuration of every domain notificationevents package. +# The generated go:generate directive in each package (generate.gen.go) passes +# the package name (-package) and the output file (-o), so neither is set here. +generate: + models: true +compatibility: + disable-required-readonly-as-pointer: true + always-prefix-enum-values: true +output-options: + nullable-type: true + skip-prune: true diff --git a/api/spec/packages/aip/package.json b/api/spec/packages/aip/package.json index c48d2b9674..5c6a65853a 100644 --- a/api/spec/packages/aip/package.json +++ b/api/spec/packages/aip/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "type": "module", "scripts": { - "generate": "tsp compile --config tspconfig.yaml ./src && node ./scripts/flatten-allof.mjs ./output/definitions/metering-and-billing/v3/openapi.MeteringAndBilling.yaml && node ./scripts/seal-object-schemas.mjs ./output/definitions/metering-and-billing/v3/*.yaml", + "generate": "tsp compile --config tspconfig.yaml ./src && node ./scripts/flatten-allof.mjs ./output/definitions/metering-and-billing/v3/openapi.MeteringAndBilling.yaml && node ./scripts/seal-object-schemas.mjs ./output/definitions/metering-and-billing/v3/*.yaml && tsp compile --config tspconfig.notification-events.yaml ./src/notification-events.tsp && node ./scripts/generate-notification-events.mjs", "bundle": "openapi bundle output/definitions/metering-and-billing/v3/openapi.OpenMeter.yaml", "watch": "tsp compile --watch --config tspconfig.yaml ./src", "test": "node --test test/*.test.js", @@ -19,6 +19,7 @@ }, "devDependencies": { "@openmeter/typespec-go": "workspace:*", + "@openmeter/typespec-notification-events": "workspace:*", "@openmeter/typespec-typescript": "workspace:*", "@redocly/cli": "2.40.0", "@types/node": "26.1.1", diff --git a/api/spec/packages/aip/scripts/generate-notification-events.mjs b/api/spec/packages/aip/scripts/generate-notification-events.mjs new file mode 100644 index 0000000000..9cfe558c4a --- /dev/null +++ b/api/spec/packages/aip/scripts/generate-notification-events.mjs @@ -0,0 +1,84 @@ +// Generates the per-domain Notification Event artifacts and copies them into +// the Go module. +// +// Run after `tsp compile --config tspconfig.notification-events.yaml`, which +// emits the JSON Schemas, the generated Go files and manifest.json. For every @goPackage +// namespace in the manifest this script: +// 1. compiles a generated entry point that makes the namespace an OpenAPI +// service, producing the events-only OpenAPI document of its types; +// 2. copies that document to its committed location under api/, and the +// emitted generate.gen.go, events.gen.go and JSON Schemas into the Go +// package directory. `go generate` then runs oapi-codegen through the +// directive in generate.gen.go, like every other oapi-codegen target. +import { execFileSync } from 'node:child_process' +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const repoRoot = resolve(pkgRoot, '../../../..') +const out = join(pkgRoot, 'output/notification-events') +const manifest = JSON.parse( + readFileSync(join(out, '_go/manifest.json'), 'utf8'), +) + +for (const entry of manifest) { + const entryDir = join(out, 'entries') + const entryFile = join(entryDir, `${entry.dir.replaceAll('/', '_')}.tsp`) + const openapiDir = join(out, 'openapi', entry.dir) + mkdirSync(entryDir, { recursive: true }) + writeFileSync( + entryFile, + [ + `import "${relative(entryDir, join(pkgRoot, 'src/notification-events.tsp'))}";`, + '', + `@@TypeSpec.service(${entry.namespace}, #{ title: "${entry.namespace}" });`, + '', + ].join('\n'), + ) + execFileSync( + 'tsp', + [ + 'compile', + '--config', + join(pkgRoot, 'tspconfig.notification-events-openapi.yaml'), + entryFile, + '--option', + `@typespec/openapi3.emitter-output-dir=${openapiDir}`, + ], + { cwd: pkgRoot, stdio: 'inherit' }, + ) + // Same post-processing as v3: oapi-codegen and kin-openapi need + // additionalProperties: false instead of the emitter's { not: {} }. + execFileSync( + 'node', + [ + join(pkgRoot, 'scripts/seal-object-schemas.mjs'), + join(openapiDir, 'openapi.yaml'), + ], + { + stdio: 'inherit', + }, + ) + + const goDir = join(repoRoot, entry.dir) + mkdirSync(goDir, { recursive: true }) + mkdirSync(dirname(join(repoRoot, entry.spec)), { recursive: true }) + cpSync(join(openapiDir, 'openapi.yaml'), join(repoRoot, entry.spec)) + cpSync( + join(out, '_go', entry.dir, 'generate.gen.go'), + join(goDir, 'generate.gen.go'), + ) + rmSync(join(goDir, 'events.gen.go'), { force: true }) + rmSync(join(goDir, 'schemas'), { recursive: true, force: true }) + if (entry.schemas.length > 0) { + cpSync( + join(out, '_go', entry.dir, 'events.gen.go'), + join(goDir, 'events.gen.go'), + ) + mkdirSync(join(goDir, 'schemas')) + for (const { source, target } of entry.schemas) { + cpSync(join(out, 'jsonschema', source), join(goDir, 'schemas', target)) + } + } +} diff --git a/api/spec/packages/aip/src/billing/notification-events/index.tsp b/api/spec/packages/aip/src/billing/notification-events/index.tsp new file mode 100644 index 0000000000..de42abb45f --- /dev/null +++ b/api/spec/packages/aip/src/billing/notification-events/index.tsp @@ -0,0 +1 @@ +import "./invoice.tsp"; diff --git a/api/spec/packages/aip/src/billing/notification-events/invoice.tsp b/api/spec/packages/aip/src/billing/notification-events/invoice.tsp new file mode 100644 index 0000000000..ebc32a3920 --- /dev/null +++ b/api/spec/packages/aip/src/billing/notification-events/invoice.tsp @@ -0,0 +1,48 @@ +import "@typespec/json-schema"; +import "@openmeter/typespec-notification-events"; +import "../../shared/index.tsp"; + +using TypeSpec.JsonSchema; +using NotificationCodegen; + +/** + * Notification Event contracts owned by the billing domain. + */ +namespace Billing.NotificationEvents; + +/** + * Payload of `v1.billing.invoice.created`. + * + * The payload identifies the invoice and carries its state at creation time; + * consumers fetch the full invoice through the API when they need it. + */ +@jsonSchema +@id("v1.billing.invoice.created.payload") +@eventType("v1.billing.invoice.created") +@friendlyName("BillingInvoiceCreatedV1Payload") +model InvoiceCreatedV1Payload { + /** + * Invoice that was created. + */ + invoice_id: Shared.ULID; + + /** + * Customer the invoice belongs to. + */ + customer_id: Shared.ULID; + + /** + * Invoice status at creation time. + */ + status: string; + + /** + * Invoice currency. + */ + currency: Shared.CurrencyCode; + + /** + * Invoice total at creation time. + */ + total: Shared.Numeric; +} diff --git a/api/spec/packages/aip/src/entitlements/notification-events/balance-threshold.tsp b/api/spec/packages/aip/src/entitlements/notification-events/balance-threshold.tsp new file mode 100644 index 0000000000..7c5e6e1d72 --- /dev/null +++ b/api/spec/packages/aip/src/entitlements/notification-events/balance-threshold.tsp @@ -0,0 +1,99 @@ +import "@typespec/json-schema"; +import "@openmeter/typespec-notification-events"; +import "../../shared/index.tsp"; + +using TypeSpec.JsonSchema; +using NotificationCodegen; + +/** + * Notification Event contracts owned by the entitlements domain. + */ +namespace Entitlements.NotificationEvents; + +/** + * Condition kind of an entitlement balance threshold. + */ +@friendlyName("EntitlementBalanceThresholdType") +enum BalanceThresholdType { + /** + * Triggers when the remaining balance drops to or below the value. + */ + BalanceAtOrBelow: "balance_at_or_below", + + /** + * Triggers when the used percentage of the entitlement reaches the value. + */ + UsagePercentageAtOrAbove: "usage_percentage_at_or_above", +} + +/** + * A single entitlement balance threshold. + */ +@friendlyName("EntitlementBalanceThreshold") +model BalanceThreshold { + /** + * Condition kind of the threshold. + */ + type: BalanceThresholdType; + + /** + * Threshold value, interpreted according to the threshold type. + */ + value: float64; +} + +/** + * Rule configuration of `v1.entitlement.balance_threshold.triggered`. + */ +@jsonSchema +@id("v1.entitlement.balance_threshold.triggered.rule_config") +@friendlyName("EntitlementBalanceThresholdTriggeredV1RuleConfig") +model BalanceThresholdTriggeredV1RuleConfig { + /** + * Thresholds evaluated against the entitlement balance. + */ + @minItems(1) + thresholds: BalanceThreshold[]; + + /** + * Feature keys the Rule is limited to. All features when omitted. + */ + features?: Shared.ResourceKey[]; +} + +/** + * Payload of `v1.entitlement.balance_threshold.triggered`. + */ +@jsonSchema +@id("v1.entitlement.balance_threshold.triggered.payload") +@eventType( + "v1.entitlement.balance_threshold.triggered", + BalanceThresholdTriggeredV1RuleConfig +) +@friendlyName("EntitlementBalanceThresholdTriggeredV1Payload") +model BalanceThresholdTriggeredV1Payload { + /** + * Customer owning the entitlement. + */ + customer_id: Shared.ULID; + + /** + * Entitlement whose balance crossed the threshold. + */ + entitlement_id: Shared.ULID; + + /** + * Feature of the entitlement. + */ + feature_key: Shared.ResourceKey; + + /** + * Balance at evaluation time. + */ + current_balance: float64; + + /** + * Threshold that was crossed. + */ + threshold: BalanceThreshold; +} diff --git a/api/spec/packages/aip/src/entitlements/notification-events/index.tsp b/api/spec/packages/aip/src/entitlements/notification-events/index.tsp new file mode 100644 index 0000000000..37dcdff455 --- /dev/null +++ b/api/spec/packages/aip/src/entitlements/notification-events/index.tsp @@ -0,0 +1 @@ +import "./balance-threshold.tsp"; diff --git a/api/spec/packages/aip/src/notification-events.tsp b/api/spec/packages/aip/src/notification-events.tsp new file mode 100644 index 0000000000..22e53995ec --- /dev/null +++ b/api/spec/packages/aip/src/notification-events.tsp @@ -0,0 +1,24 @@ +import "@openmeter/typespec-notification-events"; +// Common aliases the top-level Billing namespace and Shared depends on Common, +// so every compilation touching Shared needs Billing in the program. +import "./common/error.tsp"; +import "./common/pagination.tsp"; +import "./billing/index.tsp"; +import "./billing/notification-events/index.tsp"; +import "./entitlements/notification-events/index.tsp"; + +using NotificationCodegen; + +// Maps every Notification Event namespace to the Go package generated from it. +// Adding a producing domain means importing its notification-events folder and +// adding one @@goPackage here; scripts/generate-notification-events.mjs picks +// it up. +@@goPackage( + Entitlements.NotificationEvents, + "github.com/openmeterio/openmeter/openmeter/entitlement/notificationevents" +); + +@@goPackage( + Billing.NotificationEvents, + "github.com/openmeterio/openmeter/openmeter/billing/notificationevents" +); diff --git a/api/spec/packages/aip/tspconfig.notification-events-openapi.yaml b/api/spec/packages/aip/tspconfig.notification-events-openapi.yaml new file mode 100644 index 0000000000..8f3871c87e --- /dev/null +++ b/api/spec/packages/aip/tspconfig.notification-events-openapi.yaml @@ -0,0 +1,12 @@ +# Events-only OpenAPI document of one @goPackage namespace, the input of +# oapi-codegen for the namespace's Go package. Driven by +# scripts/generate-notification-events.mjs, which generates the entry point and +# sets emitter-output-dir. +emit: + - '@typespec/openapi3' +options: + '@typespec/openapi3': + seal-object-schemas: true +linter: + extends: + - '@openmeter/api-spec-aip/all' diff --git a/api/spec/packages/aip/tspconfig.notification-events.yaml b/api/spec/packages/aip/tspconfig.notification-events.yaml new file mode 100644 index 0000000000..47a493ff1e --- /dev/null +++ b/api/spec/packages/aip/tspconfig.notification-events.yaml @@ -0,0 +1,27 @@ +# Notification Event contracts: JSON Schemas of Event payloads and Rule +# configurations, the generated Go Event type files and the manifest consumed by +# scripts/generate-notification-events.mjs. +emit: + - '@typespec/json-schema' + - '@openmeter/typespec-notification-events' +parameters: + base-dir: + default: '{cwd}' +output-dir: '{base-dir}' +options: + '@typespec/json-schema': + emitter-output-dir: '{output-dir}/output/notification-events/jsonschema' + file-type: json + seal-object-schemas: true + '@openmeter/typespec-notification-events': + # The leading underscore keeps Go tooling from treating the emitted .go + # files as packages of the root module. + emitter-output-dir: '{output-dir}/output/notification-events/_go' + module-path: 'github.com/openmeterio/openmeter' + eventtype-import-path: 'github.com/openmeterio/openmeter/openmeter/notification/v4' + # Repository-relative paths referenced by the generated go:generate directives. + oapi-codegen-config: 'api/spec/packages/aip/oapi-codegen.notification-events.yaml' + spec-dir: 'api/notificationevents' +linter: + extends: + - '@openmeter/api-spec-aip/all' diff --git a/api/spec/packages/typespec-notification-events/lib/index.js b/api/spec/packages/typespec-notification-events/lib/index.js new file mode 100644 index 0000000000..15b059f0a7 --- /dev/null +++ b/api/spec/packages/typespec-notification-events/lib/index.js @@ -0,0 +1,282 @@ +import { + createTypeSpecLibrary, + emitFile, + getFriendlyName, + getNamespaceFullName, + paramMessage, + resolvePath, +} from '@typespec/compiler' +import { getId, isJsonSchemaDeclaration } from '@typespec/json-schema' +import { posix } from 'node:path' + +export const $lib = createTypeSpecLibrary({ + name: '@openmeter/typespec-notification-events', + diagnostics: { + 'invalid-event-type': { + severity: 'error', + messages: { + default: paramMessage`Event type "${'eventType'}" must be v. with a positive version and at least two lowercase snake_case name segments.`, + }, + }, + 'invalid-contract': { + severity: 'error', + messages: { + default: paramMessage`${'detail'}`, + }, + }, + 'event-type-outside-go-package': { + severity: 'error', + messages: { + default: + 'Event payloads must be declared directly in a namespace decorated with @goPackage.', + }, + }, + }, + emitter: { + options: { + type: 'object', + additionalProperties: false, + properties: { + 'module-path': { type: 'string' }, + 'eventtype-import-path': { type: 'string' }, + 'oapi-codegen-config': { type: 'string' }, + 'spec-dir': { type: 'string' }, + }, + required: [ + 'module-path', + 'eventtype-import-path', + 'oapi-codegen-config', + 'spec-dir', + ], + }, + }, +}) + +const goPackageKey = $lib.createStateSymbol('goPackage') +const eventTypeKey = $lib.createStateSymbol('eventType') + +// Canonical Event type: v., where the name has at least two +// lowercase snake_case segments (.). +const eventTypePattern = + /^v([1-9][0-9]*)\.([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+)$/ + +export const $decorators = { + NotificationCodegen: { + goPackage(context, target, path) { + context.program.stateMap(goPackageKey).set(target, { path }) + }, + eventType(context, target, eventType, ruleConfig) { + const match = eventTypePattern.exec(eventType) + if (!match) { + context.program.reportDiagnostic( + $lib.createDiagnostic({ + code: 'invalid-event-type', + format: { eventType }, + target: context.decoratorTarget, + }), + ) + return + } + context.program.stateMap(eventTypeKey).set(target, { + eventType, + version: Number(match[1]), + name: match[2], + ruleConfig, + }) + }, + }, +} + +// The Go identifier oapi-codegen derives from the OpenAPI component name, +// which the AIP linter requires to be set through @friendlyName. +const goName = (program, type) => getFriendlyName(program, type) ?? type.name + +// Released contracts are immutable and identified by their canonical Event +// type, so the contract roots carry it in every name: the JSON Schema $id +// (.payload / .rule_config) and the friendly name +// (...VPayload / ...VRuleConfig, sharing one stem). +// Nested types stay unversioned; changing one requires a new type. +export function $onValidate(program) { + const packages = program.stateMap(goPackageKey) + const invalid = (target, detail) => + program.reportDiagnostic( + $lib.createDiagnostic({ + code: 'invalid-contract', + format: { detail }, + target, + }), + ) + + for (const [model, e] of program.stateMap(eventTypeKey)) { + if (!packages.has(model.namespace)) { + program.reportDiagnostic( + $lib.createDiagnostic({ + code: 'event-type-outside-go-package', + target: model, + }), + ) + } + + const payload = goName(program, model) + const payloadSuffix = `V${e.version}Payload` + const hasPayloadSuffix = payload.endsWith(payloadSuffix) + if (!hasPayloadSuffix) { + invalid( + model, + `Payload ${payload} of ${e.eventType} must have a friendly name ending in ${payloadSuffix}.`, + ) + } + + const roots = [[model, `${e.eventType}.payload`]] + if (e.ruleConfig) { + roots.push([e.ruleConfig, `${e.eventType}.rule_config`]) + // The expected name derives from the payload's, so it is only + // meaningful once the payload name itself is valid. + const expected = payload.replace(/Payload$/, 'RuleConfig') + if (hasPayloadSuffix && goName(program, e.ruleConfig) !== expected) { + invalid( + e.ruleConfig, + `Rule configuration of ${e.eventType} must have the friendly name ${expected}.`, + ) + } + } + for (const [type, id] of roots) { + if (!isJsonSchemaDeclaration(program, type)) { + invalid(type, `${type.name} must be decorated with @jsonSchema.`) + } + if (getId(program, type) !== id) { + invalid(type, `${type.name} must be decorated with @id("${id}").`) + } + } + } +} + +// Emits, under the emitter output directory: +// - /generate.gen.go for every @goPackage namespace, holding +// the go:generate directive that runs oapi-codegen on the package's +// events-only OpenAPI document with the shared configuration; +// - /events.gen.go for every package owning @eventType +// payloads: the Event type value and the embedded JSON Schemas; +// - manifest.json describing every @goPackage namespace, used by +// scripts/generate-notification-events.mjs to generate the events-only +// OpenAPI documents and copy them, the Go files and the schemas into the +// repository. +// +// Paths in options and in the manifest are relative to the repository root. +export async function $onEmit(context) { + const program = context.program + const modulePath = context.options['module-path'] + const eventTypeImportPath = context.options['eventtype-import-path'] + const eventTypePkg = eventTypeImportPath.split('/').pop() + const oapiCodegenConfig = context.options['oapi-codegen-config'] + const specDir = context.options['spec-dir'] + + const eventsByNamespace = new Map() + for (const [model, def] of program.stateMap(eventTypeKey)) { + const list = eventsByNamespace.get(model.namespace) ?? [] + list.push({ model, ...def }) + eventsByNamespace.set(model.namespace, list) + } + + const manifest = [] + for (const [ns, { path }] of program.stateMap(goPackageKey)) { + if (!path.startsWith(modulePath + '/')) { + throw new Error(`@goPackage path ${path} is outside module ${modulePath}`) + } + const dir = path.slice(modulePath.length + 1) + const pkgName = path.split('/').pop() + // openmeter/entitlement/notificationevents -> /entitlement/openapi.yaml + const domain = dir.replace(/^openmeter\//, '').replace(/\/[^/]+$/, '') + const spec = posix.join(specDir, domain, 'openapi.yaml') + const header = [ + '// Code generated by @openmeter/typespec-notification-events, DO NOT EDIT.', + '', + `package ${pkgName}`, + '', + ] + + await emitFile(program, { + path: resolvePath(context.emitterOutputDir, dir, 'generate.gen.go'), + content: [ + ...header, + [ + '//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen', + `-config ${posix.relative(dir, oapiCodegenConfig)}`, + `-package ${pkgName}`, + '-o payloads.gen.go', + posix.relative(dir, spec), + ].join(' '), + '', + ].join('\n'), + }) + + const schemas = [] + const events = (eventsByNamespace.get(ns) ?? []).sort((a, b) => + a.eventType.localeCompare(b.eventType), + ) + if (events.length > 0) { + const lines = [ + ...header, + 'import (', + '\t_ "embed"', + '', + `\t"${eventTypeImportPath}"`, + ')', + '', + ] + for (const e of events) { + const payload = goName(program, e.model) + const stem = payload.replace(/Payload$/, '') + // @typespec/json-schema names files after the TypeSpec declaration; + // the copy into the Go package renames them after their $id. + const payloadSchema = `${e.eventType}.payload.json` + schemas.push({ source: `${e.model.name}.json`, target: payloadSchema }) + lines.push( + `// ${stem}EventType is ${e.eventType}.`, + `var ${stem}EventType = ${eventTypePkg}.EventType{`, + `\tName: "${e.name}",`, + `\tVersion: ${e.version},`, + '}', + '', + `// ${stem}PayloadJSONSchema is the JSON Schema (draft 2020-12) of ${payload}.`, + '//', + `//go:embed schemas/${payloadSchema}`, + `var ${stem}PayloadJSONSchema []byte`, + '', + ) + if (e.ruleConfig) { + const ruleConfigSchema = `${e.eventType}.rule_config.json` + schemas.push({ + source: `${e.ruleConfig.name}.json`, + target: ruleConfigSchema, + }) + lines.push( + `// ${stem}RuleConfigJSONSchema is the JSON Schema (draft 2020-12) of`, + `// ${goName(program, e.ruleConfig)}, the Rule configuration of ${e.eventType}.`, + '//', + `//go:embed schemas/${ruleConfigSchema}`, + `var ${stem}RuleConfigJSONSchema []byte`, + '', + ) + } + } + await emitFile(program, { + path: resolvePath(context.emitterOutputDir, dir, 'events.gen.go'), + content: lines.join('\n'), + }) + } + + manifest.push({ + namespace: getNamespaceFullName(ns), + dir, + package: pkgName, + spec, + schemas, + }) + } + + await emitFile(program, { + path: resolvePath(context.emitterOutputDir, 'manifest.json'), + content: JSON.stringify(manifest, null, 2) + '\n', + }) +} diff --git a/api/spec/packages/typespec-notification-events/lib/main.tsp b/api/spec/packages/typespec-notification-events/lib/main.tsp new file mode 100644 index 0000000000..f392d1b096 --- /dev/null +++ b/api/spec/packages/typespec-notification-events/lib/main.tsp @@ -0,0 +1,32 @@ +import "./index.js"; + +/** + * Decorators that drive Notification Event contract code generation: which Go + * package owns a TypeSpec namespace, and which payload models are Event types. + */ +namespace NotificationCodegen; + +/** + * Declares the Go package generated from every model, enum and union declared + * directly in the namespace. + * + * @param path Go import path of the package. + */ +extern dec goPackage( + target: TypeSpec.Reflection.Namespace, + path: valueof string +); + +/** + * Declares the model as the payload of a canonical Notification Event type. + * + * @param eventType Canonical Event type, `v.`, e.g. + * `v1.entitlement.balance_threshold.triggered`. + * @param ruleConfig Rule configuration model; omitted for system Event types + * that no Rule can produce. + */ +extern dec eventType( + target: TypeSpec.Reflection.Model, + eventType: valueof string, + ruleConfig?: TypeSpec.Reflection.Model +); diff --git a/api/spec/packages/typespec-notification-events/package.json b/api/spec/packages/typespec-notification-events/package.json new file mode 100644 index 0000000000..ab5df31f02 --- /dev/null +++ b/api/spec/packages/typespec-notification-events/package.json @@ -0,0 +1,25 @@ +{ + "name": "@openmeter/typespec-notification-events", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "tspMain": "lib/main.tsp", + "exports": { + ".": { + "typespec": "./lib/main.tsp", + "default": "./lib/index.js" + } + }, + "scripts": { + "test": "vitest --run" + }, + "dependencies": { + "@typespec/compiler": "1.14.0", + "@typespec/json-schema": "1.14.0", + "@typespec/openapi": "1.14.0" + }, + "devDependencies": { + "vitest": "4.1.10" + } +} diff --git a/api/spec/packages/typespec-notification-events/test/notification-events.test.js b/api/spec/packages/typespec-notification-events/test/notification-events.test.js new file mode 100644 index 0000000000..7c50b1cd8c --- /dev/null +++ b/api/spec/packages/typespec-notification-events/test/notification-events.test.js @@ -0,0 +1,189 @@ +import { resolvePath } from '@typespec/compiler' +import { + createTester, + expectDiagnosticEmpty, + expectDiagnostics, +} from '@typespec/compiler/testing' +import { describe, expect, it } from 'vitest' + +const Tester = createTester(resolvePath(import.meta.dirname, '..'), { + libraries: [ + '@typespec/json-schema', + '@openmeter/typespec-notification-events', + ], +}) + .importLibraries() + .using('TypeSpec.JsonSchema', 'NotificationCodegen') + +const lib = '@openmeter/typespec-notification-events' + +// A rule-produced Event type with every contract convention satisfied; each +// test overrides one declaration to break exactly one convention. +function contract({ + goPackage = '@goPackage("github.com/example/mod/openmeter/widget/notificationevents")', + eventType = 'v1.widget.thing.happened', + payloadName = 'WidgetThingHappenedV1Payload', + payloadId = 'v1.widget.thing.happened.payload', + ruleConfigName = 'WidgetThingHappenedV1RuleConfig', + ruleConfigJsonSchema = '@jsonSchema', +} = {}) { + return ` + ${goPackage} + namespace Widget.NotificationEvents { + ${ruleConfigJsonSchema} + @id("v1.widget.thing.happened.rule_config") + @friendlyName("${ruleConfigName}") + model ThingHappenedV1RuleConfig { + limit: int32; + } + + @jsonSchema + @id("${payloadId}") + @eventType("${eventType}", ThingHappenedV1RuleConfig) + @friendlyName("${payloadName}") + model ThingHappenedV1Payload { + thing_id: string; + } + } + ` +} + +describe('contract validation', () => { + it('accepts a contract that follows every convention', async () => { + expectDiagnosticEmpty(await Tester.diagnose(contract())) + }) + + it('rejects a malformed Event type', async () => { + const diagnostics = await Tester.diagnose( + contract({ eventType: 'widget.thing.happened' }), + ) + + expectDiagnostics(diagnostics, { code: `${lib}/invalid-event-type` }) + }) + + it('rejects an Event type outside a @goPackage namespace', async () => { + const diagnostics = await Tester.diagnose(contract({ goPackage: '' })) + + expectDiagnostics(diagnostics, { + code: `${lib}/event-type-outside-go-package`, + }) + }) + + it('rejects a payload name without the version suffix and reports only that', async () => { + // The Rule configuration name is derived from the payload name, so a + // second diagnostic about it would name a wrong expectation. + const diagnostics = await Tester.diagnose( + contract({ payloadName: 'WidgetThingHappenedPayload' }), + ) + + expectDiagnostics(diagnostics, { + code: `${lib}/invalid-contract`, + message: + 'Payload WidgetThingHappenedPayload of v1.widget.thing.happened must have a friendly name ending in V1Payload.', + }) + }) + + it('rejects a Rule configuration name that does not match the payload', async () => { + const diagnostics = await Tester.diagnose( + contract({ ruleConfigName: 'WidgetThingRuleConfig' }), + ) + + expectDiagnostics(diagnostics, { + code: `${lib}/invalid-contract`, + message: + 'Rule configuration of v1.widget.thing.happened must have the friendly name WidgetThingHappenedV1RuleConfig.', + }) + }) + + it('rejects a contract root without @jsonSchema', async () => { + const diagnostics = await Tester.diagnose( + contract({ ruleConfigJsonSchema: '' }), + ) + + expectDiagnostics(diagnostics, { + code: `${lib}/invalid-contract`, + message: 'ThingHappenedV1RuleConfig must be decorated with @jsonSchema.', + }) + }) + + it('rejects a contract root whose @id does not match its Event type', async () => { + const diagnostics = await Tester.diagnose( + contract({ payloadId: 'widget.thing.happened.payload' }), + ) + + expectDiagnostics(diagnostics, { + code: `${lib}/invalid-contract`, + message: + 'ThingHappenedV1Payload must be decorated with @id("v1.widget.thing.happened.payload").', + }) + }) +}) + +describe('emitter', () => { + const EmitTester = Tester.emit(lib, { + 'module-path': 'github.com/example/mod', + 'eventtype-import-path': 'github.com/example/mod/openmeter/notification/v4', + 'oapi-codegen-config': 'api/oapi-codegen.yaml', + 'spec-dir': 'api/notificationevents', + }) + + it('emits the go:generate directive, Event types and manifest of a package', async () => { + const { outputs } = await EmitTester.compile(contract()) + + expect(outputs['openmeter/widget/notificationevents/generate.gen.go']) + .toBe(`// Code generated by @openmeter/typespec-notification-events, DO NOT EDIT. + +package notificationevents + +//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config ../../../api/oapi-codegen.yaml -package notificationevents -o payloads.gen.go ../../../api/notificationevents/widget/openapi.yaml +`) + + expect(outputs['openmeter/widget/notificationevents/events.gen.go']) + .toBe(`// Code generated by @openmeter/typespec-notification-events, DO NOT EDIT. + +package notificationevents + +import ( + _ "embed" + + "github.com/example/mod/openmeter/notification/v4" +) + +// WidgetThingHappenedV1EventType is v1.widget.thing.happened. +var WidgetThingHappenedV1EventType = v4.EventType{ + Name: "widget.thing.happened", + Version: 1, +} + +// WidgetThingHappenedV1PayloadJSONSchema is the JSON Schema (draft 2020-12) of WidgetThingHappenedV1Payload. +// +//go:embed schemas/v1.widget.thing.happened.payload.json +var WidgetThingHappenedV1PayloadJSONSchema []byte + +// WidgetThingHappenedV1RuleConfigJSONSchema is the JSON Schema (draft 2020-12) of +// WidgetThingHappenedV1RuleConfig, the Rule configuration of v1.widget.thing.happened. +// +//go:embed schemas/v1.widget.thing.happened.rule_config.json +var WidgetThingHappenedV1RuleConfigJSONSchema []byte +`) + + expect(JSON.parse(outputs['manifest.json'])).toEqual([ + { + namespace: 'Widget.NotificationEvents', + dir: 'openmeter/widget/notificationevents', + package: 'notificationevents', + spec: 'api/notificationevents/widget/openapi.yaml', + schemas: [ + { + source: 'ThingHappenedV1Payload.json', + target: 'v1.widget.thing.happened.payload.json', + }, + { + source: 'ThingHappenedV1RuleConfig.json', + target: 'v1.widget.thing.happened.rule_config.json', + }, + ], + }, + ]) + }) +}) diff --git a/api/spec/pnpm-lock.yaml b/api/spec/pnpm-lock.yaml index 72e121e9d1..adf68f73fd 100644 --- a/api/spec/pnpm-lock.yaml +++ b/api/spec/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@openmeter/typespec-go': specifier: workspace:* version: link:../typespec-go + '@openmeter/typespec-notification-events': + specifier: workspace:* + version: link:../typespec-notification-events '@openmeter/typespec-typescript': specifier: workspace:* version: link:../typespec-typescript @@ -180,6 +183,22 @@ importers: specifier: 4.1.12 version: 4.1.12(@types/node@26.1.1)(@vitest/coverage-v8@4.1.12)(vite@8.1.6(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + packages/typespec-notification-events: + dependencies: + '@typespec/compiler': + specifier: 1.14.0 + version: 1.14.0(patch_hash=4ba2da18fa7e2cd90e9c499debf14fc53ec5b6abae9eb2f5ddcb0c2eb52c0717)(@types/node@26.1.1) + '@typespec/json-schema': + specifier: 1.14.0 + version: 1.14.0(@typespec/compiler@1.14.0(patch_hash=4ba2da18fa7e2cd90e9c499debf14fc53ec5b6abae9eb2f5ddcb0c2eb52c0717)(@types/node@26.1.1)) + '@typespec/openapi': + specifier: 1.14.0 + version: 1.14.0(patch_hash=a717b240dcdc3d23f8b752d783844b65ef750bad173549d92db12a5a0131183c)(@typespec/compiler@1.14.0(patch_hash=4ba2da18fa7e2cd90e9c499debf14fc53ec5b6abae9eb2f5ddcb0c2eb52c0717)(@types/node@26.1.1))(@typespec/http@1.14.0(patch_hash=2c55262cf42b2d54e5db3b8fe28abb4ab702cf9dbf34b5650c367270ec6ee5ef)(@typespec/compiler@1.14.0(patch_hash=4ba2da18fa7e2cd90e9c499debf14fc53ec5b6abae9eb2f5ddcb0c2eb52c0717)(@types/node@26.1.1))(@typespec/streams@0.84.0(@typespec/compiler@1.14.0(patch_hash=4ba2da18fa7e2cd90e9c499debf14fc53ec5b6abae9eb2f5ddcb0c2eb52c0717)(@types/node@26.1.1)))) + devDependencies: + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + packages/typespec-typescript: dependencies: '@alloy-js/core': diff --git a/go.mod b/go.mod index 608ae69b7c..57fa548a2d 100644 --- a/go.mod +++ b/go.mod @@ -69,6 +69,7 @@ require ( github.com/samber/lo v1.53.0 github.com/samber/mo v1.17.0 github.com/samber/slog-multi v1.8.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/shopspring/decimal v1.5.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -187,7 +188,6 @@ require ( github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/samber/slog-common v0.21.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/sony/gobreaker v1.0.0 // indirect diff --git a/openmeter/billing/notificationevents/events.gen.go b/openmeter/billing/notificationevents/events.gen.go new file mode 100644 index 0000000000..3c29c04dbd --- /dev/null +++ b/openmeter/billing/notificationevents/events.gen.go @@ -0,0 +1,20 @@ +// Code generated by @openmeter/typespec-notification-events, DO NOT EDIT. + +package notificationevents + +import ( + _ "embed" + + "github.com/openmeterio/openmeter/openmeter/notification/v4" +) + +// BillingInvoiceCreatedV1EventType is v1.billing.invoice.created. +var BillingInvoiceCreatedV1EventType = v4.EventType{ + Name: "billing.invoice.created", + Version: 1, +} + +// BillingInvoiceCreatedV1PayloadJSONSchema is the JSON Schema (draft 2020-12) of BillingInvoiceCreatedV1Payload. +// +//go:embed schemas/v1.billing.invoice.created.payload.json +var BillingInvoiceCreatedV1PayloadJSONSchema []byte diff --git a/openmeter/billing/notificationevents/events_test.go b/openmeter/billing/notificationevents/events_test.go new file mode 100644 index 0000000000..15b14ebf51 --- /dev/null +++ b/openmeter/billing/notificationevents/events_test.go @@ -0,0 +1,41 @@ +package notificationevents + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/stretchr/testify/require" +) + +func TestInvoiceCreatedContract(t *testing.T) { + require.Equal(t, "v1.billing.invoice.created", BillingInvoiceCreatedV1EventType.String()) + require.NoError(t, BillingInvoiceCreatedV1EventType.Validate()) + + // given the schema generated from the same TypeSpec model as the struct + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(BillingInvoiceCreatedV1PayloadJSONSchema)) + require.NoError(t, err) + + c := jsonschema.NewCompiler() + require.NoError(t, c.AddResource("schema.json", doc)) + + schema, err := c.Compile("schema.json") + require.NoError(t, err) + + // when business logic marshals the generated struct + raw, err := json.Marshal(BillingInvoiceCreatedV1Payload{ + InvoiceId: "01J2KNAG7SY9X6V8E5P4D3C2ZF", + CustomerId: "01J2KNBH8TZ0Y7W9F6Q5E4D3AG", + Status: "draft", + Currency: "USD", + Total: "12.50", + }) + require.NoError(t, err) + + payload, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + require.NoError(t, err) + + // then the schema accepts it + require.NoError(t, schema.Validate(payload)) +} diff --git a/openmeter/billing/notificationevents/generate.gen.go b/openmeter/billing/notificationevents/generate.gen.go new file mode 100644 index 0000000000..f081f046fb --- /dev/null +++ b/openmeter/billing/notificationevents/generate.gen.go @@ -0,0 +1,5 @@ +// Code generated by @openmeter/typespec-notification-events, DO NOT EDIT. + +package notificationevents + +//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config ../../../api/spec/packages/aip/oapi-codegen.notification-events.yaml -package notificationevents -o payloads.gen.go ../../../api/notificationevents/billing/openapi.yaml diff --git a/openmeter/billing/notificationevents/notificationevents.go b/openmeter/billing/notificationevents/notificationevents.go new file mode 100644 index 0000000000..a4bff9809d --- /dev/null +++ b/openmeter/billing/notificationevents/notificationevents.go @@ -0,0 +1,5 @@ +// Package notificationevents contains the Notification Event contracts owned +// by the billing domain. Everything else in it is generated from +// api/spec/packages/aip/src/billing/notification-events; run `make gen-api` +// after changing the TypeSpec. +package notificationevents diff --git a/openmeter/billing/notificationevents/payloads.gen.go b/openmeter/billing/notificationevents/payloads.gen.go new file mode 100644 index 0000000000..7285644806 --- /dev/null +++ b/openmeter/billing/notificationevents/payloads.gen.go @@ -0,0 +1,36 @@ +// Package notificationevents provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. +package notificationevents + +// BillingInvoiceCreatedV1Payload Payload of `v1.billing.invoice.created`. +// +// The payload identifies the invoice and carries its state at creation time; +// consumers fetch the full invoice through the API when they need it. +type BillingInvoiceCreatedV1Payload struct { + // Currency Invoice currency. + Currency CurrencyCode `json:"currency"` + + // CustomerId Customer the invoice belongs to. + CustomerId ULID `json:"customer_id"` + + // InvoiceId Invoice that was created. + InvoiceId ULID `json:"invoice_id"` + + // Status Invoice status at creation time. + Status string `json:"status"` + + // Total Invoice total at creation time. + Total Numeric `json:"total"` +} + +// CurrencyCode Three-letter [ISO4217](https://www.iso.org/iso-4217-currency-codes.html) +// currency code. Custom three-letter currency codes are also supported for +// convenience. +type CurrencyCode = string + +// Numeric Numeric represents an arbitrary precision number. +type Numeric = string + +// ULID ULID (Universally Unique Lexicographically Sortable Identifier). +type ULID = string diff --git a/openmeter/billing/notificationevents/schemas/v1.billing.invoice.created.payload.json b/openmeter/billing/notificationevents/schemas/v1.billing.invoice.created.payload.json new file mode 100644 index 0000000000..b8574ca923 --- /dev/null +++ b/openmeter/billing/notificationevents/schemas/v1.billing.invoice.created.payload.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "v1.billing.invoice.created.payload", + "type": "object", + "properties": { + "invoice_id": { + "$ref": "#/$defs/ULID", + "description": "Invoice that was created." + }, + "customer_id": { + "$ref": "#/$defs/ULID", + "description": "Customer the invoice belongs to." + }, + "status": { + "type": "string", + "description": "Invoice status at creation time." + }, + "currency": { + "$ref": "#/$defs/CurrencyCode", + "description": "Invoice currency." + }, + "total": { + "$ref": "#/$defs/Numeric", + "description": "Invoice total at creation time." + } + }, + "required": [ + "invoice_id", + "customer_id", + "status", + "currency", + "total" + ], + "unevaluatedProperties": { + "not": {} + }, + "description": "Payload of `v1.billing.invoice.created`.\n\nThe payload identifies the invoice and carries its state at creation time;\nconsumers fetch the full invoice through the API when they need it.", + "$defs": { + "ULID": { + "type": "string", + "examples": [ + "01G65Z755AFWAKHE12NY0CQ9FH" + ], + "pattern": "^[0-7][0-9A-HJKMNP-TV-Z]{25}$", + "description": "ULID (Universally Unique Lexicographically Sortable Identifier).", + "title": "ULID" + }, + "CurrencyCode": { + "type": "string", + "examples": [ + "USD" + ], + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Three-letter [ISO4217](https://www.iso.org/iso-4217-currency-codes.html)\ncurrency code. Custom three-letter currency codes are also supported for\nconvenience." + }, + "Numeric": { + "type": "string", + "pattern": "^\\-?[0-9]+(\\.[0-9]+)?$", + "description": "Numeric represents an arbitrary precision number." + } + } +} \ No newline at end of file diff --git a/openmeter/entitlement/notificationevents/events.gen.go b/openmeter/entitlement/notificationevents/events.gen.go new file mode 100644 index 0000000000..bfc509d384 --- /dev/null +++ b/openmeter/entitlement/notificationevents/events.gen.go @@ -0,0 +1,26 @@ +// Code generated by @openmeter/typespec-notification-events, DO NOT EDIT. + +package notificationevents + +import ( + _ "embed" + + "github.com/openmeterio/openmeter/openmeter/notification/v4" +) + +// EntitlementBalanceThresholdTriggeredV1EventType is v1.entitlement.balance_threshold.triggered. +var EntitlementBalanceThresholdTriggeredV1EventType = v4.EventType{ + Name: "entitlement.balance_threshold.triggered", + Version: 1, +} + +// EntitlementBalanceThresholdTriggeredV1PayloadJSONSchema is the JSON Schema (draft 2020-12) of EntitlementBalanceThresholdTriggeredV1Payload. +// +//go:embed schemas/v1.entitlement.balance_threshold.triggered.payload.json +var EntitlementBalanceThresholdTriggeredV1PayloadJSONSchema []byte + +// EntitlementBalanceThresholdTriggeredV1RuleConfigJSONSchema is the JSON Schema (draft 2020-12) of +// EntitlementBalanceThresholdTriggeredV1RuleConfig, the Rule configuration of v1.entitlement.balance_threshold.triggered. +// +//go:embed schemas/v1.entitlement.balance_threshold.triggered.rule_config.json +var EntitlementBalanceThresholdTriggeredV1RuleConfigJSONSchema []byte diff --git a/openmeter/entitlement/notificationevents/events_test.go b/openmeter/entitlement/notificationevents/events_test.go new file mode 100644 index 0000000000..a858f751d0 --- /dev/null +++ b/openmeter/entitlement/notificationevents/events_test.go @@ -0,0 +1,96 @@ +package notificationevents + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/stretchr/testify/require" +) + +func compileSchema(t *testing.T, raw []byte) *jsonschema.Schema { + t.Helper() + + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + require.NoError(t, err) + + c := jsonschema.NewCompiler() + require.NoError(t, c.AddResource("schema.json", doc)) + + s, err := c.Compile("schema.json") + require.NoError(t, err) + + return s +} + +func validateJSON(t *testing.T, s *jsonschema.Schema, raw string) error { + t.Helper() + + v, err := jsonschema.UnmarshalJSON(strings.NewReader(raw)) + require.NoError(t, err) + + return s.Validate(v) +} + +func TestBalanceThresholdTriggeredContract(t *testing.T) { + require.Equal(t, "v1.entitlement.balance_threshold.triggered", EntitlementBalanceThresholdTriggeredV1EventType.String()) + require.NoError(t, EntitlementBalanceThresholdTriggeredV1EventType.Validate()) + + t.Run("payload struct matches payload schema", func(t *testing.T) { + // given the schema generated from the same TypeSpec model as the struct + schema := compileSchema(t, EntitlementBalanceThresholdTriggeredV1PayloadJSONSchema) + + // when business logic marshals the generated struct + raw, err := json.Marshal(EntitlementBalanceThresholdTriggeredV1Payload{ + CustomerId: "01J2KNAG7SY9X6V8E5P4D3C2ZF", + EntitlementId: "01J2KNBH8TZ0Y7W9F6Q5E4D3AG", + FeatureKey: "api_requests", + CurrentBalance: 18, + Threshold: EntitlementBalanceThreshold{ + Type: EntitlementBalanceThresholdTypeBalanceAtOrBelow, + Value: 20, + }, + }) + require.NoError(t, err) + + // then the schema accepts it + require.NoError(t, validateJSON(t, schema, string(raw))) + }) + + t.Run("payload schema rejects unknown properties and enum values", func(t *testing.T) { + schema := compileSchema(t, EntitlementBalanceThresholdTriggeredV1PayloadJSONSchema) + + require.Error(t, validateJSON(t, schema, `{ + "customer_id": "01J2KNAG7SY9X6V8E5P4D3C2ZF", + "entitlement_id": "01J2KNBH8TZ0Y7W9F6Q5E4D3AG", + "feature_key": "api_requests", + "current_balance": 18, + "threshold": {"type": "balance_at_or_below", "value": 20}, + "unexpected": true + }`)) + + require.Error(t, validateJSON(t, schema, `{ + "customer_id": "01J2KNAG7SY9X6V8E5P4D3C2ZF", + "entitlement_id": "01J2KNBH8TZ0Y7W9F6Q5E4D3AG", + "feature_key": "api_requests", + "current_balance": 18, + "threshold": {"type": "bogus", "value": 20} + }`)) + }) + + t.Run("rule config struct matches rule config schema", func(t *testing.T) { + schema := compileSchema(t, EntitlementBalanceThresholdTriggeredV1RuleConfigJSONSchema) + + raw, err := json.Marshal(EntitlementBalanceThresholdTriggeredV1RuleConfig{ + Thresholds: []EntitlementBalanceThreshold{ + {Type: EntitlementBalanceThresholdTypeUsagePercentageAtOrAbove, Value: 80}, + }, + }) + require.NoError(t, err) + require.NoError(t, validateJSON(t, schema, string(raw))) + + require.Error(t, validateJSON(t, schema, `{"thresholds": []}`), "at least one threshold is required") + }) +} diff --git a/openmeter/entitlement/notificationevents/generate.gen.go b/openmeter/entitlement/notificationevents/generate.gen.go new file mode 100644 index 0000000000..815434b3dc --- /dev/null +++ b/openmeter/entitlement/notificationevents/generate.gen.go @@ -0,0 +1,5 @@ +// Code generated by @openmeter/typespec-notification-events, DO NOT EDIT. + +package notificationevents + +//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config ../../../api/spec/packages/aip/oapi-codegen.notification-events.yaml -package notificationevents -o payloads.gen.go ../../../api/notificationevents/entitlement/openapi.yaml diff --git a/openmeter/entitlement/notificationevents/notificationevents.go b/openmeter/entitlement/notificationevents/notificationevents.go new file mode 100644 index 0000000000..b288819da8 --- /dev/null +++ b/openmeter/entitlement/notificationevents/notificationevents.go @@ -0,0 +1,5 @@ +// Package notificationevents contains the Notification Event contracts owned +// by the entitlements domain. Everything else in it is generated from +// api/spec/packages/aip/src/entitlements/notification-events; run +// `make gen-api` after changing the TypeSpec. +package notificationevents diff --git a/openmeter/entitlement/notificationevents/payloads.gen.go b/openmeter/entitlement/notificationevents/payloads.gen.go new file mode 100644 index 0000000000..f2f38043ca --- /dev/null +++ b/openmeter/entitlement/notificationevents/payloads.gen.go @@ -0,0 +1,67 @@ +// Package notificationevents provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. +package notificationevents + +// Defines values for EntitlementBalanceThresholdType. +const ( + EntitlementBalanceThresholdTypeBalanceAtOrBelow EntitlementBalanceThresholdType = "balance_at_or_below" + EntitlementBalanceThresholdTypeUsagePercentageAtOrAbove EntitlementBalanceThresholdType = "usage_percentage_at_or_above" +) + +// Valid indicates whether the value is a known member of the EntitlementBalanceThresholdType enum. +func (e EntitlementBalanceThresholdType) Valid() bool { + switch e { + case EntitlementBalanceThresholdTypeBalanceAtOrBelow: + return true + case EntitlementBalanceThresholdTypeUsagePercentageAtOrAbove: + return true + default: + return false + } +} + +// EntitlementBalanceThreshold A single entitlement balance threshold. +type EntitlementBalanceThreshold struct { + // Type Condition kind of the threshold. + Type EntitlementBalanceThresholdType `json:"type"` + + // Value Threshold value, interpreted according to the threshold type. + Value float64 `json:"value"` +} + +// EntitlementBalanceThresholdTriggeredV1Payload Payload of `v1.entitlement.balance_threshold.triggered`. +type EntitlementBalanceThresholdTriggeredV1Payload struct { + // CurrentBalance Balance at evaluation time. + CurrentBalance float64 `json:"current_balance"` + + // CustomerId Customer owning the entitlement. + CustomerId ULID `json:"customer_id"` + + // EntitlementId Entitlement whose balance crossed the threshold. + EntitlementId ULID `json:"entitlement_id"` + + // FeatureKey Feature of the entitlement. + FeatureKey ResourceKey `json:"feature_key"` + + // Threshold Threshold that was crossed. + Threshold EntitlementBalanceThreshold `json:"threshold"` +} + +// EntitlementBalanceThresholdTriggeredV1RuleConfig Rule configuration of `v1.entitlement.balance_threshold.triggered`. +type EntitlementBalanceThresholdTriggeredV1RuleConfig struct { + // Features Feature keys the Rule is limited to. All features when omitted. + Features *[]ResourceKey `json:"features,omitempty"` + + // Thresholds Thresholds evaluated against the entitlement balance. + Thresholds []EntitlementBalanceThreshold `json:"thresholds"` +} + +// EntitlementBalanceThresholdType Condition kind of an entitlement balance threshold. +type EntitlementBalanceThresholdType string + +// ResourceKey A key is a unique string that is used to identify a resource. +type ResourceKey = string + +// ULID ULID (Universally Unique Lexicographically Sortable Identifier). +type ULID = string diff --git a/openmeter/entitlement/notificationevents/schemas/v1.entitlement.balance_threshold.triggered.payload.json b/openmeter/entitlement/notificationevents/schemas/v1.entitlement.balance_threshold.triggered.payload.json new file mode 100644 index 0000000000..5f5b688b37 --- /dev/null +++ b/openmeter/entitlement/notificationevents/schemas/v1.entitlement.balance_threshold.triggered.payload.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "v1.entitlement.balance_threshold.triggered.payload", + "type": "object", + "properties": { + "customer_id": { + "$ref": "#/$defs/ULID", + "description": "Customer owning the entitlement." + }, + "entitlement_id": { + "$ref": "#/$defs/ULID", + "description": "Entitlement whose balance crossed the threshold." + }, + "feature_key": { + "$ref": "#/$defs/ResourceKey", + "description": "Feature of the entitlement." + }, + "current_balance": { + "type": "number", + "description": "Balance at evaluation time." + }, + "threshold": { + "$ref": "#/$defs/BalanceThreshold", + "description": "Threshold that was crossed." + } + }, + "required": [ + "customer_id", + "entitlement_id", + "feature_key", + "current_balance", + "threshold" + ], + "unevaluatedProperties": { + "not": {} + }, + "description": "Payload of `v1.entitlement.balance_threshold.triggered`.", + "$defs": { + "ULID": { + "type": "string", + "examples": [ + "01G65Z755AFWAKHE12NY0CQ9FH" + ], + "pattern": "^[0-7][0-9A-HJKMNP-TV-Z]{25}$", + "description": "ULID (Universally Unique Lexicographically Sortable Identifier).", + "title": "ULID" + }, + "ResourceKey": { + "type": "string", + "examples": [ + "resource_key" + ], + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:_[a-z0-9]+)*$", + "description": "A key is a unique string that is used to identify a resource.", + "title": "Resource Key" + }, + "BalanceThreshold": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/BalanceThresholdType", + "description": "Condition kind of the threshold." + }, + "value": { + "type": "number", + "description": "Threshold value, interpreted according to the threshold type." + } + }, + "required": [ + "type", + "value" + ], + "unevaluatedProperties": { + "not": {} + }, + "description": "A single entitlement balance threshold." + }, + "BalanceThresholdType": { + "type": "string", + "enum": [ + "balance_at_or_below", + "usage_percentage_at_or_above" + ], + "description": "Condition kind of an entitlement balance threshold." + } + } +} \ No newline at end of file diff --git a/openmeter/entitlement/notificationevents/schemas/v1.entitlement.balance_threshold.triggered.rule_config.json b/openmeter/entitlement/notificationevents/schemas/v1.entitlement.balance_threshold.triggered.rule_config.json new file mode 100644 index 0000000000..d910900669 --- /dev/null +++ b/openmeter/entitlement/notificationevents/schemas/v1.entitlement.balance_threshold.triggered.rule_config.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "v1.entitlement.balance_threshold.triggered.rule_config", + "type": "object", + "properties": { + "thresholds": { + "type": "array", + "items": { + "$ref": "#/$defs/BalanceThreshold" + }, + "minItems": 1, + "description": "Thresholds evaluated against the entitlement balance." + }, + "features": { + "type": "array", + "items": { + "$ref": "#/$defs/ResourceKey" + }, + "description": "Feature keys the Rule is limited to. All features when omitted." + } + }, + "required": [ + "thresholds" + ], + "unevaluatedProperties": { + "not": {} + }, + "description": "Rule configuration of `v1.entitlement.balance_threshold.triggered`.", + "$defs": { + "BalanceThreshold": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/BalanceThresholdType", + "description": "Condition kind of the threshold." + }, + "value": { + "type": "number", + "description": "Threshold value, interpreted according to the threshold type." + } + }, + "required": [ + "type", + "value" + ], + "unevaluatedProperties": { + "not": {} + }, + "description": "A single entitlement balance threshold." + }, + "ResourceKey": { + "type": "string", + "examples": [ + "resource_key" + ], + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:_[a-z0-9]+)*$", + "description": "A key is a unique string that is used to identify a resource.", + "title": "Resource Key" + }, + "BalanceThresholdType": { + "type": "string", + "enum": [ + "balance_at_or_below", + "usage_percentage_at_or_above" + ], + "description": "Condition kind of an entitlement balance threshold." + } + } +} \ No newline at end of file diff --git a/openmeter/notification/v4/event.go b/openmeter/notification/v4/event.go new file mode 100644 index 0000000000..f47602d98a --- /dev/null +++ b/openmeter/notification/v4/event.go @@ -0,0 +1,47 @@ +// Package v4 holds the Notification vNext types, starting with the identity of +// Notification Event types. Every domain's generated notificationevents package +// imports it, so it must not depend on Notification or any domain package. +package v4 + +import ( + "errors" + "fmt" + "regexp" + + "github.com/openmeterio/openmeter/pkg/models" +) + +// namePattern matches . or .. +// with lowercase snake_case segments. Keep it in sync with the @eventType check +// in api/spec/packages/typespec-notification-events. +var namePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$`) + +var _ models.Validator = (*EventType)(nil) + +// EventType is a versioned Event type. Its canonical string form, +// v., crosses API, Kafka, persistence and Provider boundaries. +// Versions have no compatibility guarantee: a changed contract is a new +// version. +type EventType struct { + Name string + Version int +} + +// String returns the canonical form, e.g. v1.billing.invoice.created. +func (t EventType) String() string { + return fmt.Sprintf("v%d.%s", t.Version, t.Name) +} + +func (t EventType) Validate() error { + var errs []error + + if !namePattern.MatchString(t.Name) { + errs = append(errs, fmt.Errorf("name %q must have at least two lowercase snake_case segments", t.Name)) + } + + if t.Version < 1 { + errs = append(errs, fmt.Errorf("version must be positive, got %d", t.Version)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/openmeter/notification/v4/event_test.go b/openmeter/notification/v4/event_test.go new file mode 100644 index 0000000000..3668ce2a90 --- /dev/null +++ b/openmeter/notification/v4/event_test.go @@ -0,0 +1,23 @@ +package v4 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEventType(t *testing.T) { + et := EventType{Name: "billing.invoice.created", Version: 1} + + require.Equal(t, "v1.billing.invoice.created", et.String()) + require.NoError(t, et.Validate()) + + for _, invalid := range []EventType{ + {Name: "invoice", Version: 1}, + {Name: "Billing.invoice.created", Version: 1}, + {Name: "billing.invoice.created.", Version: 1}, + {Name: "billing.invoice.created", Version: 0}, + } { + require.Error(t, invalid.Validate(), invalid.String()) + } +}