From 0c41c7ab599ae3a8b40c3859169cf2cc1c380356 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Fri, 5 Dec 2025 00:22:02 +0100 Subject: [PATCH 01/18] feat(api): v3 implementation --- .../definitions/konnect_properties.yaml | 36 ++ api/spec/src/v3/apps/stripe/customer.tsp | 22 + api/spec/src/v3/common/properties.tsp | 43 +- api/spec/src/v3/customers/customer.tsp | 116 +++++ api/spec/src/v3/customers/index.tsp | 2 + api/spec/src/v3/customers/operations.tsp | 44 ++ api/spec/src/v3/entitlements/entitlement.tsp | 37 ++ api/spec/src/v3/entitlements/index.tsp | 1 + api/spec/src/v3/entitlements/operations.tsp | 26 ++ api/spec/src/v3/events/event.tsp | 2 - api/spec/src/v3/events/operations.tsp | 4 +- api/spec/src/v3/konnect.tsp | 35 +- api/spec/src/v3/meters/index.tsp | 2 + api/spec/src/v3/meters/meter.tsp | 82 ++++ api/spec/src/v3/meters/operations.tsp | 37 ++ api/spec/src/v3/openmeter.tsp | 35 +- api/spec/src/v3/shared/consts.tsp | 13 +- api/spec/src/v3/shared/index.tsp | 4 +- api/spec/src/v3/shared/pagination.tsp | 14 - api/spec/src/v3/shared/properties.tsp | 10 + api/spec/src/v3/shared/request.tsp | 6 + api/spec/src/v3/shared/resource.tsp | 24 +- api/spec/src/v3/shared/responses.tsp | 30 ++ api/spec/src/v3/shared/rest.tsp | 11 - api/spec/src/v3/subscriptions/index.tsp | 1 + api/spec/src/v3/subscriptions/operations.tsp | 52 +++ api/v3/api.gen.go | 438 ++++++++++++++++-- api/v3/apierrors/errors.go | 220 +++++++++ api/v3/apierrors/errors_ctors.go | 279 +++++++++++ .../apierrors/invalidparameterrules/rules.go | 31 ++ api/v3/apierrors/options.go | 19 + api/v3/handlers/convert.gen.go | 175 +++++++ api/v3/handlers/convert.go | 51 ++ api/v3/handlers/customer.go | 176 +++++++ api/v3/oasmiddleware/decoder.go | 25 + api/v3/oasmiddleware/error.go | 143 ++++++ api/v3/oasmiddleware/hook.go | 106 +++++ api/v3/oasmiddleware/response.go | 56 +++ api/v3/openapi.yaml | 418 ++++++++++++++++- api/v3/render/render.go | 86 ++++ api/v3/request/body.go | 24 + api/v3/request/pagination.go | 41 ++ api/v3/response/pagination.go | 88 ++++ api/v3/server/customers.go | 19 + api/v3/server/events.go | 12 + api/v3/server/server.go | 109 +++++ go.mod | 2 +- openmeter/server/server.go | 151 +++--- 48 files changed, 3161 insertions(+), 197 deletions(-) create mode 100644 api/spec/common/definitions/konnect_properties.yaml create mode 100644 api/spec/src/v3/apps/stripe/customer.tsp create mode 100644 api/spec/src/v3/customers/customer.tsp create mode 100644 api/spec/src/v3/customers/index.tsp create mode 100644 api/spec/src/v3/customers/operations.tsp create mode 100644 api/spec/src/v3/entitlements/entitlement.tsp create mode 100644 api/spec/src/v3/entitlements/index.tsp create mode 100644 api/spec/src/v3/entitlements/operations.tsp create mode 100644 api/spec/src/v3/meters/index.tsp create mode 100644 api/spec/src/v3/meters/meter.tsp create mode 100644 api/spec/src/v3/meters/operations.tsp delete mode 100644 api/spec/src/v3/shared/pagination.tsp create mode 100644 api/spec/src/v3/shared/request.tsp create mode 100644 api/spec/src/v3/shared/responses.tsp delete mode 100644 api/spec/src/v3/shared/rest.tsp create mode 100644 api/spec/src/v3/subscriptions/index.tsp create mode 100644 api/spec/src/v3/subscriptions/operations.tsp create mode 100644 api/v3/apierrors/errors.go create mode 100644 api/v3/apierrors/errors_ctors.go create mode 100644 api/v3/apierrors/invalidparameterrules/rules.go create mode 100644 api/v3/apierrors/options.go create mode 100644 api/v3/handlers/convert.gen.go create mode 100644 api/v3/handlers/convert.go create mode 100644 api/v3/handlers/customer.go create mode 100644 api/v3/oasmiddleware/decoder.go create mode 100644 api/v3/oasmiddleware/error.go create mode 100644 api/v3/oasmiddleware/hook.go create mode 100644 api/v3/oasmiddleware/response.go create mode 100644 api/v3/render/render.go create mode 100644 api/v3/request/body.go create mode 100644 api/v3/request/pagination.go create mode 100644 api/v3/response/pagination.go create mode 100644 api/v3/server/customers.go create mode 100644 api/v3/server/events.go create mode 100644 api/v3/server/server.go diff --git a/api/spec/common/definitions/konnect_properties.yaml b/api/spec/common/definitions/konnect_properties.yaml new file mode 100644 index 0000000000..a01361ae64 --- /dev/null +++ b/api/spec/common/definitions/konnect_properties.yaml @@ -0,0 +1,36 @@ +components: + schemas: + Labels: + title: Labels + type: object + example: + env: test + maxProperties: 50 + description: | + Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + + Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + additionalProperties: + type: string + pattern: '^[a-z0-9A-Z]{1}([a-z0-9A-Z-._]*[a-z0-9A-Z]+)?$' + minLength: 1 + maxLength: 63 + LabelsUpdate: + type: object + nullable: true + description: | + Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + + Labels are intended to store **INTERNAL** metadata. + + Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + example: + env: test + maxProperties: 50 + additionalProperties: + type: string + pattern: '^[a-z0-9A-Z]{1}([a-z0-9A-Z-._]*[a-z0-9A-Z]+)?$' + minLength: 1 + maxLength: 63 + nullable: true + writeOnly: true diff --git a/api/spec/src/v3/apps/stripe/customer.tsp b/api/spec/src/v3/apps/stripe/customer.tsp new file mode 100644 index 0000000000..da72531568 --- /dev/null +++ b/api/spec/src/v3/apps/stripe/customer.tsp @@ -0,0 +1,22 @@ +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/openapi3"; +import "../../shared/index.tsp"; + +using TypeSpec.Http; +using TypeSpec.OpenAPI; + +namespace Apps.Stripe; + +interface CustomerStripeEndpoints { + // /** + // * Get stripe app data for a customer. + // * Only returns data if the customer billing profile is linked to a stripe app. + // */ + // @get + // @operationId("getCustomerStripeAppData") + // @summary("Get customer stripe app data") + // get( + // @path customerIdOrKey: Shared.ULID | Shared.ExternalResourceKey, + // ): Shared.GetResponse | Common.NotFound | Common.ErrorResponses; +} diff --git a/api/spec/src/v3/common/properties.tsp b/api/spec/src/v3/common/properties.tsp index 05eb8c15ef..f6c9e6fa8a 100644 --- a/api/spec/src/v3/common/properties.tsp +++ b/api/spec/src/v3/common/properties.tsp @@ -5,26 +5,26 @@ using TypeSpec.OpenAPI; namespace Common; -/** - * An ISO-8601 timestamp representation of entity creation date. - */ -@useRef("../../../../common/definitions/properties.yaml#/components/schemas/CreatedAt") -@friendlyName("CreatedAt") -model CreatedAt {} +// /** +// * An ISO-8601 timestamp representation of entity creation date. +// */ +// @useRef("../../../../common/definitions/properties.yaml#/components/schemas/CreatedAt") +// @friendlyName("CreatedAt") +// model CreatedAt {} -/** - * An ISO-8601 timestamp representation of entity last update date. - */ -@useRef("../../../../common/definitions/properties.yaml#/components/schemas/UpdatedAt") -@friendlyName("UpdatedAt") -model UpdatedAt {} +// /** +// * An ISO-8601 timestamp representation of entity last update date. +// */ +// @useRef("../../../../common/definitions/properties.yaml#/components/schemas/UpdatedAt") +// @friendlyName("UpdatedAt") +// model UpdatedAt {} -/** - * An ISO-8601 timestamp representation of entity deletion date. - */ -@useRef("../../../../common/definitions/properties.yaml#/components/schemas/DeletedAt") -@friendlyName("DeletedAt") -model DeletedAt {} +// /** +// * An ISO-8601 timestamp representation of entity deletion date. +// */ +// @useRef("../../../../common/definitions/properties.yaml#/components/schemas/DeletedAt") +// @friendlyName("DeletedAt") +// model DeletedAt {} /** * Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. @@ -34,3 +34,10 @@ model DeletedAt {} @useRef("../../../../common/definitions/konnect_properties.yaml#/components/schemas/Labels") @friendlyName("Labels") model Labels {} + +/** + * Public labels store information about an entity that can be used for filtering a list of objects. + */ +@useRef("../../../../common/definitions/konnect_properties.yaml#/components/schemas/PublicLabels") +@friendlyName("PublicLabels") +model PublicLabels {} diff --git a/api/spec/src/v3/customers/customer.tsp b/api/spec/src/v3/customers/customer.tsp new file mode 100644 index 0000000000..c148f7c6e3 --- /dev/null +++ b/api/spec/src/v3/customers/customer.tsp @@ -0,0 +1,116 @@ +import "../shared/index.tsp"; + +namespace Customers; + +/** + * Customers can be individuals or organizations that can subscribe to plans and have access to features. + */ +@friendlyName("BillingCustomer") +model Customer { + ...Shared.ResourceWithKey; + + /** + * Mapping to attribute metered usage to the customer by the event subject. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Usage Attribution") + usage_attribution?: CustomerUsageAttribution; + + /** + * The primary email address of the customer. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Primary Email") + primary_email?: string; + + /** + * Currency of the customer. + * Used for billing, tax and invoicing. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Currency") + currency?: Shared.CurrencyCode; + + /** + * The billing address of the customer. + * Used for tax and invoicing. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Billing Address") + billing_address?: Address; +} + +/** + * Address + */ +@friendlyName("BillingAddress") +model Address { + /** + * Country code in [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) alpha-2 format. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Country") + country?: Shared.CountryCode; + + /** + * Postal code. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Postal Code") + postal_code?: string; + + /** + * State or province. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("State") + state?: string; + + /** + * City. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("City") + city?: string; + + /** + * First line of the address. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Line 1") + line1?: string; + + /** + * Second line of the address. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Line 2") + line2?: string; + + /** + * Phone number. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Phone Number") + phone_number?: string; +} + +/** + * Mapping to attribute metered usage to the customer. + * One customer can have zero or more subjects, + * but one subject can only belong to one customer. + */ +@friendlyName("BillingCustomerUsageAttribution") +model CustomerUsageAttribution { + /** + * The subjects that are attributed to the customer. + * Can be empty when no usage event subjects are associated with the customer. + */ + @visibility(Lifecycle.Create, Lifecycle.Read, Lifecycle.Update) + @summary("Subject Keys") + @minItems(0) + subject_keys: UsageAttributionKey[]; +} + +@minLength(1) +scalar UsageAttributionKey extends string; diff --git a/api/spec/src/v3/customers/index.tsp b/api/spec/src/v3/customers/index.tsp new file mode 100644 index 0000000000..ebd2919dd3 --- /dev/null +++ b/api/spec/src/v3/customers/index.tsp @@ -0,0 +1,2 @@ +import "./customer.tsp"; +import "./operations.tsp"; diff --git a/api/spec/src/v3/customers/operations.tsp b/api/spec/src/v3/customers/operations.tsp new file mode 100644 index 0000000000..2c138dd8f1 --- /dev/null +++ b/api/spec/src/v3/customers/operations.tsp @@ -0,0 +1,44 @@ +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/openapi"; +import "@typespec/openapi3"; +import "../common/error.tsp"; +import "../common/pagination.tsp"; +import "../common/parameters.tsp"; +import "../shared/index.tsp"; +import "./customer.tsp"; + +using TypeSpec.Http; +using TypeSpec.OpenAPI; + +namespace Customers; + +interface CustomersOperations { + @post + @operationId("create-customer") + @summary("Create customer") + @extension(Shared.UnstableExtension, true) + @extension(Shared.InternalExtension, true) + create( + @body + customer: Shared.CreateRequest, + ): Shared.CreateResponse | Common.ErrorResponses; + + @get + @operationId("get-customer") + @summary("Get customer") + @extension(Shared.UnstableExtension, true) + @extension(Shared.InternalExtension, true) + get( + @path customerId: Shared.ULID, + ): Shared.GetResponse | Common.NotFound | Common.ErrorResponses; + + @get + @operationId("list-customers") + @summary("List customers") + @extension(Shared.UnstableExtension, true) + @extension(Shared.InternalExtension, true) + list( + ...Common.CursorPageQuery, + ): Shared.CursorPaginatedResponse | Common.ErrorResponses; +} diff --git a/api/spec/src/v3/entitlements/entitlement.tsp b/api/spec/src/v3/entitlements/entitlement.tsp new file mode 100644 index 0000000000..36753bf766 --- /dev/null +++ b/api/spec/src/v3/entitlements/entitlement.tsp @@ -0,0 +1,37 @@ +namespace Entitlements; + +/** + * The type of the entitlement. + */ +@friendlyName("BillingEntitlementType") +enum EntitlementType { + metered, + static, + boolean, +} + +/** + * Entitlement check result. + */ +@friendlyName("BillingEntitlementCheck") +model EntitlementCheck { + /** + * The type of the entitlement. + */ + @visibility(Lifecycle.Read) + type: EntitlementType; + + /** + * Whether the customer has access to the feature. + */ + @visibility(Lifecycle.Read) + has_access: boolean; + + /** + * Only available for static entitlements. + */ + @example("{ \"rateLimit\": 100 }") + @encode("json") + @visibility(Lifecycle.Read) + config?: string; +} diff --git a/api/spec/src/v3/entitlements/index.tsp b/api/spec/src/v3/entitlements/index.tsp new file mode 100644 index 0000000000..144c4aeaff --- /dev/null +++ b/api/spec/src/v3/entitlements/index.tsp @@ -0,0 +1 @@ +import "./operations.tsp"; diff --git a/api/spec/src/v3/entitlements/operations.tsp b/api/spec/src/v3/entitlements/operations.tsp new file mode 100644 index 0000000000..4340e85486 --- /dev/null +++ b/api/spec/src/v3/entitlements/operations.tsp @@ -0,0 +1,26 @@ +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/openapi"; +import "@typespec/openapi3"; +import "../common/error.tsp"; +import "../common/pagination.tsp"; +import "../common/parameters.tsp"; +import "../shared/index.tsp"; +import "./entitlement.tsp"; + +using TypeSpec.Http; +using TypeSpec.OpenAPI; + +namespace Entitlements; + +interface CustomerEntitlementsOperations { + @get + @operationId("check-customer-feature-access") + @summary("Check customer feature access") + @extension(Shared.UnstableExtension, true) + @extension(Shared.InternalExtension, true) + get( + @path customerId: Shared.ULID, + @path featureKey: Shared.ResourceKey, + ): Shared.GetResponse | Common.NotFound | Common.ErrorResponses; +} diff --git a/api/spec/src/v3/events/event.tsp b/api/spec/src/v3/events/event.tsp index 981d7cdfc2..7e820bbed0 100644 --- a/api/spec/src/v3/events/event.tsp +++ b/api/spec/src/v3/events/event.tsp @@ -2,8 +2,6 @@ import "@typespec/openapi"; import "@typespec/openapi3"; import "../shared/index.tsp"; -using TypeSpec.OpenAPI; - namespace Events; /** diff --git a/api/spec/src/v3/events/operations.tsp b/api/spec/src/v3/events/operations.tsp index cdf03ff25d..94b0250c59 100644 --- a/api/spec/src/v3/events/operations.tsp +++ b/api/spec/src/v3/events/operations.tsp @@ -18,10 +18,10 @@ namespace Events; */ @friendlyName("IngestEventsResponse") model IngestEventsResponse { - @statusCode _: 204; + @statusCode _: 202; } -interface MeteringEventsOperations { +interface EventsOperations { /** * Ingests an event or batch of events following the CloudEvents specification. */ diff --git a/api/spec/src/v3/konnect.tsp b/api/spec/src/v3/konnect.tsp index 6b051c5be5..922fb4d310 100644 --- a/api/spec/src/v3/konnect.tsp +++ b/api/spec/src/v3/konnect.tsp @@ -4,6 +4,8 @@ import "@typespec/openapi"; import "@typespec/openapi3"; import "./shared/index.tsp"; import "./events/index.tsp"; +import "./customers/index.tsp"; +import "./entitlements/index.tsp"; using TypeSpec.Http; using TypeSpec.OpenAPI; @@ -24,17 +26,34 @@ using TypeSpec.OpenAPI; // TODO: Uncomment this when the Singapore production region is available // @server("https://sg.api.konghq.com/v3", "Singapore Production region") @server("https://global.api.konghq.com/v3", "Global Production region") -@tagMetadata( - Shared.MeteringEventsTag, - #{ description: Shared.MeteringEventsDescription } -) +@tagMetadata(Shared.EventsTag, #{ description: Shared.EventsDescription }) +@tagMetadata(Shared.CustomersTag, #{ description: Shared.CustomersDescription }) @useAuth(systemAccountAccessToken | personalAccessToken | konnectAccessToken) namespace MeteringAndBilling; -@route("/metering/events") -@tag(Shared.MeteringEventsTag) -@friendlyName("${Shared.MeteringAndBillingTitle}: ${Shared.MeteringEventsTag}") -interface MeteringEventsEndpoints extends Events.MeteringEventsOperations {} +@route("/openmeter/events") +@tag(Shared.EventsTag) +@friendlyName("${Shared.MeteringAndBillingTitle}: ${Shared.EventsTag}") +interface EventsEndpoints extends Events.EventsOperations {} + +@route("/openmeter/customers") +@tag(Shared.CustomersTag) +@friendlyName("${Shared.MeteringAndBillingTitle}: ${Shared.CustomersTag}") +interface CustomersEndpoints extends Customers.CustomersOperations {} + +// @route("/openmeter/customers/{customerId}/subscriptions") +// @tag(Shared.CustomersTag) +// @tag(Shared.SubscriptionsTag) +// @friendlyName("${Shared.MeteringAndBillingTitle}: ${Shared.SubscriptionsTag}") +// interface CustomerSubscriptionsEndpoints +// extends Subscriptions.CustomerSubscriptionsOperations {} + +// @route("/openmeter/customers/{customerId}/entitlements/{featureKey}") +// @tag(Shared.CustomersTag) +// @tag(Shared.EntitlementsTag) +// @friendlyName("${Shared.MeteringAndBillingTitle}: ${Shared.EntitlementsTag}") +// interface CustomerEntitlementsEndpoints +// extends Entitlements.CustomerEntitlementsOperations {} /** * The system account access token is meant for automations and integrations that are not directly associated with a human identity. diff --git a/api/spec/src/v3/meters/index.tsp b/api/spec/src/v3/meters/index.tsp new file mode 100644 index 0000000000..fcf19b9218 --- /dev/null +++ b/api/spec/src/v3/meters/index.tsp @@ -0,0 +1,2 @@ +import "./meter.tsp"; +import "./operations.tsp"; diff --git a/api/spec/src/v3/meters/meter.tsp b/api/spec/src/v3/meters/meter.tsp new file mode 100644 index 0000000000..58a93cf857 --- /dev/null +++ b/api/spec/src/v3/meters/meter.tsp @@ -0,0 +1,82 @@ +import "../shared/index.tsp"; + +namespace Meters; + +/** + * A meter is a configuration that defines how to match and aggregate events. + */ +@friendlyName("Meter") +@example(#{ + id: "01G65Z755AFWAKHE12NY0CQ9FH", + key: "tokens_total", + name: "Tokens Total", + description: "AI Token Usage", + aggregation: "sum", + event_type_filter: "prompt", + value_property: "$.tokens", + dimensions: #{ `model`: "$.model", type: "$.type" }, + created_at: Shared.DateTime.fromISO("2024-01-01T01:01:01.001Z"), + updated_at: Shared.DateTime.fromISO("2024-01-01T01:01:01.001Z"), +}) +model Meter { + ...Shared.ResourceWithKey; + + /** + * The aggregation type to use for the meter. + */ + @visibility(Lifecycle.Read, Lifecycle.Create) + aggregation: MeterAggregation; + + /** + * The event type to include in the aggregation. + */ + @visibility(Lifecycle.Read, Lifecycle.Create) + @minLength(1) + @example("prompt") + event_type_filter: string; + + /** + * The date since the meter should include events. + * Useful to skip old events. + * If not specified, all historical events are included. + */ + @visibility(Lifecycle.Read, Lifecycle.Create) + event_from?: Shared.DateTime; + + /** + * JSONPath expression to extract the value from the ingested event's data property. + * + * The ingested value for sum, avg, min, and max aggregations is a number or a string that can be parsed to a number. + * + * For unique_count aggregation, the ingested value must be a string. For count aggregation the value_property is ignored. + */ + @visibility(Lifecycle.Read, Lifecycle.Create) + @minLength(1) + @example("$.tokens") + value_property?: string; + + /** + * Named JSONPath expressions to extract the group by values from the event data. + * + * Keys must be unique and consist only alphanumeric and underscore characters. + * + */ + // TODO: add key format enforcement + @visibility(Lifecycle.Read, Lifecycle.Create, Lifecycle.Update) + @example(#{ type: "$.type" }) + dimensions?: Record; +} + +/** + * The aggregation type to use for the meter. + */ +@friendlyName("MeterAggregation") +union MeterAggregation { + sum: "sum", + count: "count", + unique_count: "unique_count", + avg: "avg", + min: "min", + max: "max", + latest: "latest", +} diff --git a/api/spec/src/v3/meters/operations.tsp b/api/spec/src/v3/meters/operations.tsp new file mode 100644 index 0000000000..049295f767 --- /dev/null +++ b/api/spec/src/v3/meters/operations.tsp @@ -0,0 +1,37 @@ +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/openapi"; +import "@typespec/openapi3"; +import "../common/error.tsp"; +import "../common/pagination.tsp"; +import "../common/parameters.tsp"; +import "../shared/index.tsp"; +import "./meter.tsp"; + +using TypeSpec.Http; +using TypeSpec.OpenAPI; + +namespace Meters; + +interface MetersOperations { + @post + @operationId("create-meter") + @summary("Create meter") + create( + @body meter: Shared.CreateRequest, + ): Shared.CreateResponse | Common.ErrorResponses; + + @get + @operationId("get-meter") + @summary("Get meter") + get( + @path meterId: Shared.ULID, + ): Shared.GetResponse | Common.ErrorResponses; + + @get + @operationId("list-meters") + @summary("List meters") + list( + ...Common.CursorPageQuery, + ): Shared.CursorPaginatedResponse | Common.ErrorResponses; +} diff --git a/api/spec/src/v3/openmeter.tsp b/api/spec/src/v3/openmeter.tsp index 1cb484c939..4c607693f4 100644 --- a/api/spec/src/v3/openmeter.tsp +++ b/api/spec/src/v3/openmeter.tsp @@ -4,6 +4,8 @@ import "@typespec/openapi"; import "@typespec/openapi3"; import "./shared/index.tsp"; import "./events/index.tsp"; +import "./customers/index.tsp"; +import "./subscriptions/index.tsp"; using TypeSpec.Http; using TypeSpec.OpenAPI; @@ -23,13 +25,30 @@ using TypeSpec.OpenAPI; }) @server("https://127.0.0.1/api/v3", "Local") @server("https://openmeter.cloud/api/v3", "Cloud") -@tagMetadata( - Shared.MeteringEventsTag, - #{ description: Shared.MeteringEventsDescription } -) +@tagMetadata(Shared.EventsTag, #{ description: Shared.EventsDescription }) +@tagMetadata(Shared.CustomersTag, #{ description: Shared.CustomersDescription }) namespace OpenMeter; -@route("/metering/events") -@tag(Shared.MeteringEventsTag) -@friendlyName("OpenMeter: ${Shared.MeteringEventsTag}") -interface MeteringEventsEndpoints extends Events.MeteringEventsOperations {} +@route("/openmeter/events") +@tag(Shared.EventsTag) +@friendlyName("OpenMeter: ${Shared.EventsTag}") +interface EventsEndpoints extends Events.EventsOperations {} + +@route("/openmeter/customers") +@tag(Shared.CustomersTag) +@friendlyName("OpenMeter: ${Shared.CustomersTag}") +interface CustomersEndpoints extends Customers.CustomersOperations {} + +// @route("/openmeter/customers/{customerId}/subscriptions") +// @tag(Shared.CustomersTag) +// @tag(Shared.SubscriptionsTag) +// @friendlyName("OpenMeter: ${Shared.SubscriptionsTag}") +// interface CustomerSubscriptionsEndpoints +// extends Subscriptions.CustomerSubscriptionsOperations {} + +// @route("/openmeter/customers/{customerId}/entitlements/{featureKey}") +// @tag(Shared.CustomersTag) +// @tag(Shared.EntitlementsTag) +// @friendlyName("OpenMeter: ${Shared.EntitlementsTag}") +// interface CustomerEntitlementsEndpoints +// extends Entitlements.CustomerEntitlementsOperations {} diff --git a/api/spec/src/v3/shared/consts.tsp b/api/spec/src/v3/shared/consts.tsp index 62f479239a..bd8b2cc335 100644 --- a/api/spec/src/v3/shared/consts.tsp +++ b/api/spec/src/v3/shared/consts.tsp @@ -4,8 +4,17 @@ const MeteringAndBillingTitle = "Metering & Billing"; const MetersTag = "Meters"; const MetersDescription = "Meters specify how to aggregate events for billing and analytics purposes. Meters can be configured with multiple aggregation methods and groupings. Multiple meters can be created for the same event type, enabling flexible metering scenarios."; -const MeteringEventsTag = "Metering Events"; -const MeteringEventsDescription = "Metering events are used to track usage of your product or service. Events are processed asynchronously by the meters, so they may not be immediately available for querying."; +const EventsTag = "Metering Events"; +const EventsDescription = "Metering events are used to track usage of your product or service. Events are processed asynchronously by the meters, so they may not be immediately available for querying."; + +const CustomersTag = "Billing Customers"; +const CustomersDescription = "Customers are used to track usage of your product or service. Customers can be individuals or organizations that can subscribe to plans and have access to features."; + +const EntitlementsTag = "Billing Entitlements"; +const EntitlementsDescription = "Entitlements are used to control access to features for customers."; + +const SubscriptionsTag = "Billing Subscriptions"; +const SubscriptionsDescription = "Subscriptions are used to track usage of your product or service. Subscriptions can be individuals or organizations that can subscribe to plans and have access to features."; const UnstableExtension = "x-unstable"; const InternalExtension = "x-internal"; diff --git a/api/spec/src/v3/shared/index.tsp b/api/spec/src/v3/shared/index.tsp index c470b30ab3..0219be855a 100644 --- a/api/spec/src/v3/shared/index.tsp +++ b/api/spec/src/v3/shared/index.tsp @@ -1,7 +1,7 @@ import "./consts.tsp"; import "./filters.tsp"; -import "./pagination.tsp"; import "./parameters.tsp"; import "./properties.tsp"; +import "./request.tsp"; import "./resource.tsp"; -import "./rest.tsp"; +import "./responses.tsp"; diff --git a/api/spec/src/v3/shared/pagination.tsp b/api/spec/src/v3/shared/pagination.tsp deleted file mode 100644 index 40e83ceca8..0000000000 --- a/api/spec/src/v3/shared/pagination.tsp +++ /dev/null @@ -1,14 +0,0 @@ -import "../common/pagination.tsp"; - -namespace Shared; - -/** - * Cursor paginated response. - */ -@friendlyName("{name}PaginatedResponse", T) -model CursorPaginatedResponse { - @pageItems - data: T[]; - - meta: Common.CursorMeta; -} diff --git a/api/spec/src/v3/shared/properties.tsp b/api/spec/src/v3/shared/properties.tsp index 77c6d96dde..3eee2f9f3a 100644 --- a/api/spec/src/v3/shared/properties.tsp +++ b/api/spec/src/v3/shared/properties.tsp @@ -26,6 +26,16 @@ scalar ULID extends string; @example("resource_key") scalar ResourceKey extends string; +/** + * ExternalResourceKey is a unique string that is used to identify a resource in an external system. + */ +@maxLength(256) +@minLength(1) +@friendlyName("ExternalResourceKey") +@summary("External Resource Key") +@example("019ae40f-4258-7f15-9491-842f42a7d6ac") +scalar ExternalResourceKey extends string; + /** * [RFC3339](https://tools.ietf.org/html/rfc3339) formatted date-time string in UTC. */ diff --git a/api/spec/src/v3/shared/request.tsp b/api/spec/src/v3/shared/request.tsp new file mode 100644 index 0000000000..3caef6b1b1 --- /dev/null +++ b/api/spec/src/v3/shared/request.tsp @@ -0,0 +1,6 @@ +namespace Shared; + +@doc("{name} create request.", T) +@friendlyName("Create{name}Request", T) +@withVisibility(Lifecycle.Create) +model CreateRequest is DefaultKeyVisibility; diff --git a/api/spec/src/v3/shared/resource.tsp b/api/spec/src/v3/shared/resource.tsp index 2779a108e9..0067b733dd 100644 --- a/api/spec/src/v3/shared/resource.tsp +++ b/api/spec/src/v3/shared/resource.tsp @@ -31,25 +31,27 @@ model Resource { @visibility(Lifecycle.Read, Lifecycle.Create, Lifecycle.Update) description?: string; - #suppress "@openmeter/api-spec/doc-decorator" "shared model" - @visibility(Lifecycle.Read) - labels?: Common.Labels; - #suppress "@openmeter/api-spec/doc-decorator" "shared model" @visibility(Lifecycle.Read, Lifecycle.Create, Lifecycle.Update) - public_labels?: Common.Labels; + labels?: Common.Labels; - #suppress "@openmeter/api-spec/doc-decorator" "shared model" + /** + * An ISO-8601 timestamp representation of entity creation date. + */ @visibility(Lifecycle.Read) - created_at?: Common.CreatedAt; + created_at?: DateTime; - #suppress "@openmeter/api-spec/doc-decorator" "shared model" + /** + * An ISO-8601 timestamp representation of entity last update date. + */ @visibility(Lifecycle.Read) - updated_at?: Common.UpdatedAt; + updated_at?: DateTime; - #suppress "@openmeter/api-spec/doc-decorator" "shared model" + /** + * An ISO-8601 timestamp representation of entity deletion date. + */ @visibility(Lifecycle.Read) - deleted_at?: Common.DeletedAt; + deleted_at?: DateTime; } /** diff --git a/api/spec/src/v3/shared/responses.tsp b/api/spec/src/v3/shared/responses.tsp new file mode 100644 index 0000000000..289ee16ac8 --- /dev/null +++ b/api/spec/src/v3/shared/responses.tsp @@ -0,0 +1,30 @@ +import "@typespec/rest"; +import "@typespec/http"; +import "../common/properties.tsp"; + +namespace Shared; + +@doc("{name} response.", T) +@friendlyName("Get{name}Response", T) +model GetResponse { + @Http.statusCode _: 200; + @Http.body body: T; +} + +@doc("{name} created response.", T) +@friendlyName("Create{name}Response", T) +model CreateResponse { + @Http.statusCode _: 201; + @Http.body body: T; +} + +@doc("Cursor paginated response.") +@friendlyName("{name}PaginatedResponse", T) +model CursorPaginatedResponse { + @Http.statusCode _: 200; + + @pageItems + data: T[]; + + meta: Common.CursorMeta; +} diff --git a/api/spec/src/v3/shared/rest.tsp b/api/spec/src/v3/shared/rest.tsp deleted file mode 100644 index d5c666f005..0000000000 --- a/api/spec/src/v3/shared/rest.tsp +++ /dev/null @@ -1,11 +0,0 @@ -import "@typespec/rest"; - -namespace TypeSpec.Rest.Resource { - /** - * Resource update operation model. - * @template Resource The resource model to update with replace. - */ - @friendlyName("{name}ReplaceUpdate", Resource) - model ResourceReplaceModel - is UpdateableProperties>; -} diff --git a/api/spec/src/v3/subscriptions/index.tsp b/api/spec/src/v3/subscriptions/index.tsp new file mode 100644 index 0000000000..144c4aeaff --- /dev/null +++ b/api/spec/src/v3/subscriptions/index.tsp @@ -0,0 +1 @@ +import "./operations.tsp"; diff --git a/api/spec/src/v3/subscriptions/operations.tsp b/api/spec/src/v3/subscriptions/operations.tsp new file mode 100644 index 0000000000..f70f7bdbc7 --- /dev/null +++ b/api/spec/src/v3/subscriptions/operations.tsp @@ -0,0 +1,52 @@ +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/openapi"; +import "@typespec/openapi3"; +import "../common/error.tsp"; +import "../common/pagination.tsp"; +import "../common/parameters.tsp"; +import "../shared/index.tsp"; + +using TypeSpec.Http; +using TypeSpec.OpenAPI; + +namespace Subscriptions; + +/** + * Request to create a customer subscription. + */ +@friendlyName("BillingCreateCustomerSubscriptionRequest") +model CreateCustomerSubscriptionRequest { + /** + * The plan to subscribe to. + */ + @summary("Plan Reference") + plan: PlanReference; +} + +/** + * Reference to a plan by its id or key and version. + */ +@oneOf +@friendlyName("BillingPlanReference") +union PlanReference { + id: Shared.ULID, + keyVersion: { + key: string, + version?: string, + }, +} + +interface CustomerSubscriptionsOperations { + @post + @operationId("create-customer-subscription") + @summary("Create customer subscription") + @extension(Shared.UnstableExtension, true) + @extension(Shared.InternalExtension, true) + create( + @path customerId: Shared.ULID, + @body request: CreateCustomerSubscriptionRequest, + ): { + @statusCode _: 204; + } | Common.ErrorResponses; +} diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index 82ccc99043..7a60ad4ba1 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -135,6 +135,173 @@ type BaseError struct { Type *string `json:"type,omitempty"` } +// BillingAddress Address +type BillingAddress struct { + // City City. + City *string `json:"city,omitempty"` + + // Country Country code in [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) alpha-2 format. + Country *CountryCode `json:"country,omitempty"` + + // Line1 First line of the address. + Line1 *string `json:"line1,omitempty"` + + // Line2 Second line of the address. + Line2 *string `json:"line2,omitempty"` + + // PhoneNumber Phone number. + PhoneNumber *string `json:"phone_number,omitempty"` + + // PostalCode Postal code. + PostalCode *string `json:"postal_code,omitempty"` + + // State State or province. + State *string `json:"state,omitempty"` +} + +// BillingCustomer Customers can be individuals or organizations that can subscribe to plans and have access to features. +type BillingCustomer struct { + // BillingAddress The billing address of the customer. + // Used for tax and invoicing. + BillingAddress *BillingAddress `json:"billing_address,omitempty"` + + // CreatedAt An ISO-8601 timestamp representation of entity creation date. + CreatedAt *DateTime `json:"created_at,omitempty"` + + // Currency Currency of the customer. + // Used for billing, tax and invoicing. + Currency *CurrencyCode `json:"currency,omitempty"` + + // DeletedAt An ISO-8601 timestamp representation of entity deletion date. + DeletedAt *DateTime `json:"deleted_at,omitempty"` + + // Description Optional description of the resource. + // + // Maximum 1024 characters. + Description *string `json:"description,omitempty"` + Id ULID `json:"id"` + + // Key A key is a unique string that is used to identify a resource. + Key ResourceKey `json:"key"` + + // Labels Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + // + // Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + Labels *Labels `json:"labels,omitempty"` + + // Name Display name of the resource. + // + // Between 1 and 256 characters. + Name string `json:"name"` + + // PrimaryEmail The primary email address of the customer. + PrimaryEmail *string `json:"primary_email,omitempty"` + + // UpdatedAt An ISO-8601 timestamp representation of entity last update date. + UpdatedAt *DateTime `json:"updated_at,omitempty"` + + // UsageAttribution Mapping to attribute metered usage to the customer by the event subject. + UsageAttribution *BillingCustomerUsageAttribution `json:"usage_attribution,omitempty"` +} + +// BillingCustomerUsageAttribution Mapping to attribute metered usage to the customer. +// One customer can have zero or more subjects, +// but one subject can only belong to one customer. +type BillingCustomerUsageAttribution struct { + // SubjectKeys The subjects that are attributed to the customer. + // Can be empty when no usage event subjects are associated with the customer. + SubjectKeys []CustomersUsageAttributionKey `json:"subject_keys"` +} + +// CountryCode [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) alpha-2 country code. +// Custom two-letter country codes are also supported for convenience. +type CountryCode = string + +// CreateCustomerRequest Customer create request. +type CreateCustomerRequest struct { + // BillingAddress The billing address of the customer. + // Used for tax and invoicing. + BillingAddress *BillingAddress `json:"billing_address,omitempty"` + + // Currency Currency of the customer. + // Used for billing, tax and invoicing. + Currency *CurrencyCode `json:"currency,omitempty"` + + // Description Optional description of the resource. + // + // Maximum 1024 characters. + Description *string `json:"description,omitempty"` + + // Key A key is a unique string that is used to identify a resource. + Key ResourceKey `json:"key"` + + // Labels Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + // + // Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + Labels *Labels `json:"labels,omitempty"` + + // Name Display name of the resource. + // + // Between 1 and 256 characters. + Name string `json:"name"` + + // PrimaryEmail The primary email address of the customer. + PrimaryEmail *string `json:"primary_email,omitempty"` + + // UsageAttribution Mapping to attribute metered usage to the customer by the event subject. + UsageAttribution *BillingCustomerUsageAttribution `json:"usage_attribution,omitempty"` +} + +// 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 + +// CursorMeta Pagination metadata. +type CursorMeta struct { + Page CursorMetaPage `json:"page"` +} + +// CursorMetaPage defines model for CursorMetaPage. +type CursorMetaPage struct { + // First URI to the first page + First *string `json:"first,omitempty"` + + // Last URI to the last page + Last *string `json:"last,omitempty"` + + // Next URI to the next page + Next nullable.Nullable[string] `json:"next"` + + // Previous URI to the previous page + Previous nullable.Nullable[string] `json:"previous"` + + // Size Requested page size + Size float32 `json:"size"` +} + +// CursorPageParameters defines model for CursorPageParameters. +type CursorPageParameters struct { + // After Cursor param specifying the page (i.e. the next page) of data returned. + After *string `json:"after,omitempty"` + + // Before Cursor param specifying the page (i.e. the previous page) of data returned. + Before *string `json:"before,omitempty"` + + // Size The number of items included per page. + Size *int `json:"size,omitempty"` +} + +// CustomerPaginatedResponse Cursor paginated response. +type CustomerPaginatedResponse struct { + Data []BillingCustomer `json:"data"` + + // Meta Pagination metadata. + Meta CursorMeta `json:"meta"` +} + +// CustomersUsageAttributionKey defines model for Customers.UsageAttributionKey. +type CustomersUsageAttributionKey = string + // DateTime [RFC3339](https://tools.ietf.org/html/rfc3339) formatted date-time string in UTC. type DateTime = time.Time @@ -224,6 +391,11 @@ type InvalidParameters_Item struct { // InvalidRules invalid parameters rules type InvalidRules string +// Labels Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. +// +// Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". +type Labels map[string]string + // MeteringEvent Metering event following the CloudEvents specification. type MeteringEvent struct { // Data The event payload. @@ -258,6 +430,21 @@ type MeteringEvent struct { // MeteringEventDatacontenttype Content type of the CloudEvents data value. Only the value "application/json" is allowed over HTTP. type MeteringEventDatacontenttype string +// NotFoundError defines model for NotFoundError. +type NotFoundError struct { + Detail interface{} `json:"detail"` + Instance interface{} `json:"instance"` + Status interface{} `json:"status"` + Title interface{} `json:"title"` + Type interface{} `json:"type,omitempty"` +} + +// 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 + // UnauthorizedError defines model for UnauthorizedError. type UnauthorizedError struct { Detail interface{} `json:"detail"` @@ -267,6 +454,9 @@ type UnauthorizedError struct { Type interface{} `json:"type,omitempty"` } +// CursorPageQuery defines model for CursorPageQuery. +type CursorPageQuery = CursorPageParameters + // BadRequest defines model for BadRequest. type BadRequest = BadRequestError @@ -279,9 +469,18 @@ type Internal = BaseError // NotAvailable standard error type NotAvailable = BaseError +// NotFound defines model for NotFound. +type NotFound = NotFoundError + // Unauthorized defines model for Unauthorized. type Unauthorized = UnauthorizedError +// ListCustomersParams defines parameters for ListCustomers. +type ListCustomersParams struct { + // Page Determines which page of the collection to retrieve. + Page *CursorPageQuery `form:"page,omitempty" json:"page,omitempty"` +} + // IngestMeteringEventsApplicationCloudeventsBatchPlusJSONBody defines parameters for IngestMeteringEvents. type IngestMeteringEventsApplicationCloudeventsBatchPlusJSONBody = []MeteringEvent @@ -293,6 +492,9 @@ type IngestMeteringEventsJSONBody struct { // IngestMeteringEventsJSONBody1 defines parameters for IngestMeteringEvents. type IngestMeteringEventsJSONBody1 = []MeteringEvent +// CreateCustomerJSONRequestBody defines body for CreateCustomer for application/json ContentType. +type CreateCustomerJSONRequestBody = CreateCustomerRequest + // IngestMeteringEventsApplicationCloudeventsPlusJSONRequestBody defines body for IngestMeteringEvents for application/cloudevents+json ContentType. type IngestMeteringEventsApplicationCloudeventsPlusJSONRequestBody = MeteringEvent @@ -444,8 +646,17 @@ func (t *InvalidParameters_Item) UnmarshalJSON(b []byte) error { // ServerInterface represents all server handlers. type ServerInterface interface { + // List customers + // (GET /openmeter/customers) + ListCustomers(w http.ResponseWriter, r *http.Request, params ListCustomersParams) + // Create customer + // (POST /openmeter/customers) + CreateCustomer(w http.ResponseWriter, r *http.Request) + // Get customer + // (GET /openmeter/customers/{customerId}) + GetCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) // Ingest metering events - // (POST /metering/events) + // (POST /openmeter/events) IngestMeteringEvents(w http.ResponseWriter, r *http.Request) } @@ -453,8 +664,26 @@ type ServerInterface interface { type Unimplemented struct{} +// List customers +// (GET /openmeter/customers) +func (_ Unimplemented) ListCustomers(w http.ResponseWriter, r *http.Request, params ListCustomersParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Create customer +// (POST /openmeter/customers) +func (_ Unimplemented) CreateCustomer(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Get customer +// (GET /openmeter/customers/{customerId}) +func (_ Unimplemented) GetCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) { + w.WriteHeader(http.StatusNotImplemented) +} + // Ingest metering events -// (POST /metering/events) +// (POST /openmeter/events) func (_ Unimplemented) IngestMeteringEvents(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } @@ -468,6 +697,72 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(http.Handler) http.Handler +// ListCustomers operation middleware +func (siw *ServerInterfaceWrapper) ListCustomers(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ListCustomersParams + + // ------------- Optional query parameter "page" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page", r.URL.Query(), ¶ms.Page) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListCustomers(w, r, params) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateCustomer operation middleware +func (siw *ServerInterfaceWrapper) CreateCustomer(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateCustomer(w, r) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + +// GetCustomer operation middleware +func (siw *ServerInterfaceWrapper) GetCustomer(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "customerId" ------------- + var customerId ULID + + err = runtime.BindStyledParameterWithOptions("simple", "customerId", chi.URLParam(r, "customerId"), &customerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "customerId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCustomer(w, r, customerId) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + // IngestMeteringEvents operation middleware func (siw *ServerInterfaceWrapper) IngestMeteringEvents(w http.ResponseWriter, r *http.Request) { @@ -596,7 +891,16 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl } r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/metering/events", wrapper.IngestMeteringEvents) + r.Get(options.BaseURL+"/openmeter/customers", wrapper.ListCustomers) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/openmeter/customers", wrapper.CreateCustomer) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/openmeter/customers/{customerId}", wrapper.GetCustomer) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/openmeter/events", wrapper.IngestMeteringEvents) }) return r @@ -605,54 +909,86 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "aCkAAA==", + "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", + "Wl+v/y8AAP//fm0GumtRAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/apierrors/errors.go b/api/v3/apierrors/errors.go new file mode 100644 index 0000000000..c0334a8d0c --- /dev/null +++ b/api/v3/apierrors/errors.go @@ -0,0 +1,220 @@ +package apierrors + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/openmeterio/openmeter/api/v3/render" +) + +type config struct { + recastContextCanceled BaseAPIError +} + +// BaseAPIError is the schema for all API apierrors. +type BaseAPIError struct { + // A unique identifier for this error. When dereferenced it must provide + // human-readable documentation for the problem. The URL must follow + // (#122 - Resource Names) and must not contain URI fragments type. + Type string `json:"type"` + + // The HTTP status code of the error. Useful when passing the response body + // to child properties in a frontend UI. Must be returned as an integer. + Status int `json:"status"` + + // A short, human-readable summary of the problem. It should not change + // between occurrences of a problem, except for localization. Should be + // provided as "Sentence case" for direct use in the UI. + Title string `json:"title"` + + // Used to return the correlation ID back to the user, in the format + // {product}:trace:. This helps us find the relevant logs when a + // customer reports an issue. + Instance string `json:"instance"` + + // A human-readable explanation specific to this occurrence of the problem. + // This field may contain request/entity data to help the user understand + // what went wrong. Enclose variable values in square brackets. Should be + // provided as "Sentence case" for direct use in the UI. + Detail string `json:"detail"` + + // Used to indicate which fields have invalid values when validated. Both a + // human-readable value (reason) and a type that can be used for localised + // results (rule) are provided. + InvalidParameters InvalidParameters `json:"invalid_parameters,omitempty"` + + // UnderlyingError is the underlying error stack to be logged. + // NOTE: this should not be returned to callers. + UnderlyingError error `json:"-"` + + // The context used to extract a logger. + ctx context.Context +} + +// InvalidParameters is a collection of fields that failed input validation. +type InvalidParameters []InvalidParameter + +type InvalidParameterSource uint8 + +const ( + InvalidParamSourcePath InvalidParameterSource = iota + 1 + InvalidParamSourceQuery + InvalidParamSourceBody + InvalidParamSourceHeader +) + +func (i InvalidParameterSource) String() string { + switch i { + case InvalidParamSourceBody: + return "body" + case InvalidParamSourcePath: + return "path" + case InvalidParamSourceHeader: + return "header" + case InvalidParamSourceQuery: + return "query" + } + return "" +} + +func ToInvalid(s string) InvalidParameterSource { + switch s { + case "query": + return InvalidParamSourceQuery + case "path": + return InvalidParamSourcePath + case "body": + return InvalidParamSourceBody + case "header": + return InvalidParamSourceHeader + } + return InvalidParameterSource(0) +} + +func (i InvalidParameterSource) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +func (i *InvalidParameterSource) UnmarshalJSON(data []byte) error { + var source string + if err := json.Unmarshal(data, &source); err != nil { + return err + } + *i = ToInvalid(source) + return nil +} + +// InvalidParameter is a single field that failed input validation. +type InvalidParameter struct { + // Field concerned by the error. + Field string `json:"field"` + + // Rule represents the rule that has triggered the error. + Rule string `json:"rule,omitempty"` + + // Reason describes why the error has been triggered. + Reason string `json:"reason"` + + // Source describes where the error has been triggered: body, header, path. + Source InvalidParameterSource `json:"source"` + + // Choices represents the available choices for value in a case of an enum. + Choices []string `json:"choices,omitempty"` + + // Minimum is an optional field for setting the minimum required value for + // an attribute. + Minimum *int `json:"minimum,omitempty"` + + // Maximum is an optional field for setting the maximum required value for + // an attribute. + Maximum *int `json:"maximum,omitempty"` + + // Dependents is an optional field for when the rule "dependent_fields" is + // applied. + Dependents []string `json:"dependents,omitempty"` +} + +// Stringer method for a collection of InvalidParameter entities. +func (ips InvalidParameters) String() string { + out := new(strings.Builder) + for i, param := range ips { + out.WriteString(param.Field) + if param.Rule != "" { + _, _ = fmt.Fprintf(out, " [%s]", param.Rule) + } + _, _ = fmt.Fprintf(out, ": %s", param.Reason) + if i != len(ips)-1 { + _, _ = fmt.Fprintf(out, ", ") + } + } + return out.String() +} + +// Error satisfies the error interface. +func (bae *BaseAPIError) Error() string { + switch { + case bae.InvalidParameters != nil: + return fmt.Sprintf("%s: %s", bae.UnderlyingError, bae.InvalidParameters.String()) + case bae.Detail != "" && bae.UnderlyingError != nil: + return fmt.Sprintf("%s: %s", bae.Detail, bae.UnderlyingError) + case bae.Detail != "": + return bae.Detail + } + return bae.UnderlyingError.Error() +} + +// Unwrap returns the underlying error +func (bae *BaseAPIError) Unwrap() error { + return bae.UnderlyingError +} + +// Context is the context that created the error +func (bae *BaseAPIError) Context() context.Context { + return bae.ctx +} + +func isContextCanceledErr(err error) bool { + return errors.Is(err, context.Canceled) // || status.Code(err) == codes.Canceled +} + +// HandleAPIError is a helper function that accepts an error +func (bae *BaseAPIError) HandleAPIError( + w http.ResponseWriter, + r *http.Request, +) { + // handle: write the response to the caller + w.Header().Set(ContentTypeKey, ContentTypeProblemValue) + _ = render.RenderJSON(w, bae, render.WithStatus(bae.Status)) +} + +// handleEmptySet returns a 200 with an empty meta/data list. +func (bae *BaseAPIError) handleEmptySet(w http.ResponseWriter) { + w.WriteHeader(http.StatusOK) + if bae.Type == EmptySetType { + _ = render.RenderJSON(w, struct { + Meta struct { + Page struct { + Size int `json:"size"` + Total int `json:"total"` + Number int `json:"number"` + } `json:"page"` + } `json:"meta"` + Data []any `json:"data"` + }{}) + } else { + _ = render.RenderJSON(w, struct { + Meta struct { + Page struct { + Size int `json:"size"` + Previous *string `json:"previous"` + Next *string `json:"next"` + } `json:"page"` + } `json:"meta"` + Data []any `json:"data"` + }{}) + } +} diff --git a/api/v3/apierrors/errors_ctors.go b/api/v3/apierrors/errors_ctors.go new file mode 100644 index 0000000000..eeaed4bbbd --- /dev/null +++ b/api/v3/apierrors/errors_ctors.go @@ -0,0 +1,279 @@ +package apierrors + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi/v5/middleware" +) + +const ( + // Content Type + ContentTypeKey = "Content-Type" + ContentTypeProblemValue = "application/problem+json" + + // Internal + InternalType = "https://kongapi.info/konnect/internal" + InternalTitle = "Internal" + InternalDetail = "An internal failure occurred" + + // Service Unavailable + UnavailableType = "https://kongapi.info/konnect/unavailable" + UnavailableTitle = "Unavailable" + UnavailableDetail = "The requested service is unavailable" + + // NotImplemented + NotImplementedType = "https://kongapi.info/konnect/not-implemented" + NotImplementedTitle = "Not Implemented" + NotImplementedDetail = "The requested functionality is not implemented" + + // Unauthenticated + UnauthenticatedType = "https://kongapi.info/konnect/unauthenticated" + UnauthenticatedTitle = "Unauthenticated" + UnauthenticatedDetail = "A valid token is required" + + // Forbidden + ForbiddenType = "https://kongapi.info/konnect/unauthorized" + ForbiddenTitle = "Forbidden" + ForbiddenDetail = "Permission denied" + + // NotFound + NotFoundType = "https://kongapi.info/konnect/not-found" + NotFoundTitle = "Not Found" + NotFoundDetail = "The requested %s was not found" + NotFoundDetails = "The requested %s were not found: %v" + + // BadRequest + // NOTE: there is not `detail` attribute in a 400. + BadRequestType = "https://kongapi.info/konnect/bad-request" + BadRequestTitle = "Bad Request" + + // BadReqeust UUID Invalid Format + InvalidUUIDFormat = "invalid ID format" + + // Precondition Failed + PreconditionFailedType = "https://kongapi.info/konnect/precondition-failed" + PreconditionFailedTitle = "Precondition Failed" + + // Rate Limit + RateLimitType = "https://kongapi.info/konnect/rate-limited" + RateLimitTitle = "Rate Limited" + RateLimitDetail = "Too many requests" + + // Conflict + ConflictType = "https://kongapi.info/konnect/resource-conflict" + ConflictTitle = "Resource Conflict" + + // Empty Set + EmptySetType = "Empty Set" + EmptySetCursorType = "Empty Set Cursor" +) + +// NewInternalError generates a not found error. +func NewInternalError(ctx context.Context, err error) *BaseAPIError { + return &BaseAPIError{ + Type: InternalType, + Status: http.StatusInternalServerError, + Title: InternalTitle, + Instance: instance(ctx), + Detail: InternalDetail, + UnderlyingError: err, + ctx: ctx, + } +} + +// NewServiceUnavailable generates a not found error. +func NewServiceUnavailable(ctx context.Context, err error) *BaseAPIError { + return &BaseAPIError{ + Type: InternalType, + Status: http.StatusServiceUnavailable, + Title: InternalTitle, + Instance: instance(ctx), + Detail: InternalDetail, + UnderlyingError: err, + ctx: ctx, + } +} + +// NewUnauthenticatedError generates an unauthenticated error. +func NewUnauthenticatedError(ctx context.Context, err error) *BaseAPIError { + return &BaseAPIError{ + Type: UnauthenticatedType, + Status: http.StatusUnauthorized, + Title: UnauthenticatedTitle, + Instance: instance(ctx), + Detail: UnauthenticatedDetail, + UnderlyingError: err, + ctx: ctx, + } +} + +// NewForbiddenError generates an unauthorized error. +func NewForbiddenError(ctx context.Context, err error) *BaseAPIError { + return &BaseAPIError{ + Type: ForbiddenType, + Status: http.StatusForbidden, + Title: ForbiddenTitle, + Instance: instance(ctx), + Detail: ForbiddenDetail, + UnderlyingError: err, + ctx: ctx, + } +} + +// NewForbiddenErrorDetail generates an forbidden error with a user readable/detailed message. +func NewForbiddenErrorDetail(ctx context.Context, detailMessage string) *BaseAPIError { + return &BaseAPIError{ + Type: ForbiddenType, + Status: http.StatusForbidden, + Title: ForbiddenDetail, + Instance: instance(ctx), + Detail: MakeSentenceCase(detailMessage), + ctx: ctx, + } +} + +// NewNotFoundError generates a not found error. +func NewNotFoundError(ctx context.Context, err error, entityType string) *BaseAPIError { + if entityType != "" { + return &BaseAPIError{ + Type: NotFoundType, + Status: http.StatusNotFound, + Title: NotFoundTitle, + Instance: instance(ctx), + Detail: fmt.Sprintf(NotFoundDetail, entityType), + UnderlyingError: err, + ctx: ctx, + } + } + return &BaseAPIError{ + Type: NotFoundType, + Status: http.StatusNotFound, + Title: NotFoundTitle, + Instance: instance(ctx), + UnderlyingError: err, + ctx: ctx, + } +} + +// NewNotFoundErrors generates a not found error for multiple resources. +func NewNotFoundErrors( + ctx context.Context, + err error, + entityType string, + resources any, +) *BaseAPIError { + return &BaseAPIError{ + Type: NotFoundType, + Status: http.StatusNotFound, + Title: NotFoundTitle, + Instance: instance(ctx), + Detail: fmt.Sprintf(NotFoundDetails, entityType, resources), + UnderlyingError: err, + ctx: ctx, + } +} + +// NewBadRequestError generates a bad request error. +// TODO: this is a bit cumbersome to work with in the API handler. Need to think +// how we validate schemas and maybe generate this (more) automagically. +func NewBadRequestError(ctx context.Context, err error, invalidFields InvalidParameters) *BaseAPIError { + return &BaseAPIError{ + Type: BadRequestType, + Status: http.StatusBadRequest, + Title: BadRequestTitle, + // Instance: instance(ctx), + InvalidParameters: invalidFields, + UnderlyingError: err, + Detail: fmt.Sprintf("%s: %s", BadRequestTitle, invalidFields.String()), + ctx: ctx, + } +} + +func NewBadRequestInvalidUUIDError(ctx context.Context, err error, field string) *BaseAPIError { + return NewBadRequestError(ctx, err, InvalidParameters{ + { + Field: field, + Reason: InvalidUUIDFormat, + }, + }) +} + +// NewPreconditionFailedError generates an precondition failed error. +func NewPreconditionFailedError(ctx context.Context, precondition string) *BaseAPIError { + return &BaseAPIError{ + Type: PreconditionFailedType, + Status: http.StatusPreconditionFailed, + Title: PreconditionFailedTitle, + Instance: instance(ctx), + Detail: MakeSentenceCase(precondition), + UnderlyingError: fmt.Errorf("precondition failed: %s", precondition), + ctx: ctx, + } +} + +// NewRateLimitError generates an HTTP 429 Too Many Requests error. +func NewRateLimitError(ctx context.Context) *BaseAPIError { + return &BaseAPIError{ + Type: RateLimitType, + Status: http.StatusTooManyRequests, + Title: RateLimitDetail, + Instance: instance(ctx), + Detail: RateLimitDetail, + ctx: ctx, + } +} + +func NewConflictError(ctx context.Context, err error, detail string) *BaseAPIError { + return &BaseAPIError{ + Type: ConflictType, + Status: http.StatusConflict, + Title: ConflictTitle, + Instance: instance(ctx), + Detail: detail, + UnderlyingError: err, + ctx: ctx, + } +} + +func NewEmptySetResponse(ctx context.Context, cursorPagination bool) *BaseAPIError { + bae := &BaseAPIError{ + Status: http.StatusOK, + ctx: ctx, + } + + if cursorPagination { + bae.Type = EmptySetCursorType + } else { + bae.Type = EmptySetType + } + + return bae +} + +func NewNotImplementedError(ctx context.Context, err error) *BaseAPIError { + return &BaseAPIError{ + Type: NotImplementedType, + Status: http.StatusNotImplemented, + Title: NotImplementedTitle, + Detail: NotImplementedDetail, + ctx: ctx, + } +} + +// MakeSentenceCase takes any string and returns a Sentence case version of it +func MakeSentenceCase(msg string) string { + return strings.ToUpper(msg[:1]) + msg[1:] +} + +// instance returns the request ID from the context +// TODO: use trace ID from context instead +func instance(ctx context.Context) string { + reqID := middleware.GetReqID(ctx) + if reqID != "" { + return fmt.Sprintf("urn:request:%s", reqID) + } + return "" +} diff --git a/api/v3/apierrors/invalidparameterrules/rules.go b/api/v3/apierrors/invalidparameterrules/rules.go new file mode 100644 index 0000000000..d1020223ce --- /dev/null +++ b/api/v3/apierrors/invalidparameterrules/rules.go @@ -0,0 +1,31 @@ +package invalidparameterrules + +const ( + Required = "required" + Unique = "unique" + DependentFields = "dependent_fields" + Enum = "enum" + MinLength = "min_length" + MaxLength = "max_length" + MinItems = "min_items" + MaxItems = "max_items" + Min = "min" + Max = "max" + MinDigits = "min_digits" + MinLowercase = "min_lowercase" + MinUppercase = "min_uppercase" + MinSymbols = "min_symbols" + MinTime = "min_time" + MaxTime = "max_time" + IsArray = "is_array" + IsBoolean = "is_boolean" + IsDateTime = "is_date_time" + IsInteger = "is_integer" + IsNull = "is_null" + IsNumber = "is_number" + IsObject = "is_object" + IsString = "is_string" + IsUUID = "is_uuid" + UnknownProperty = "unknown_property" + MissingReference = "missing_reference" +) diff --git a/api/v3/apierrors/options.go b/api/v3/apierrors/options.go new file mode 100644 index 0000000000..b062213131 --- /dev/null +++ b/api/v3/apierrors/options.go @@ -0,0 +1,19 @@ +package apierrors + +type configOption func(*config) + +// ContextCanceledError is a sample error to be used with +// WithRecastContextCancelled option +var ContextCanceledError = &BaseAPIError{ + Status: 499, + Title: "Client Closed Request", + Detail: "context cancelled", +} + +// WithRecastContextCancelled sets the BaseAPIError that would be recasted into +// if an APIError is a context.Canceled. This is to avoid false 500 errors +func WithRecastContextCancelled(e BaseAPIError) configOption { // nolint:gocritic + return func(c *config) { + c.recastContextCanceled = e + } +} diff --git a/api/v3/handlers/convert.gen.go b/api/v3/handlers/convert.gen.go new file mode 100644 index 0000000000..41bd885d9a --- /dev/null +++ b/api/v3/handlers/convert.gen.go @@ -0,0 +1,175 @@ +// Code generated by github.com/jmattheis/goverter, DO NOT EDIT. +//go:build !goverter + +package handlers + +import ( + nullable "github.com/oapi-codegen/nullable" + v3 "github.com/openmeterio/openmeter/api/v3" + response "github.com/openmeterio/openmeter/api/v3/response" + customer "github.com/openmeterio/openmeter/openmeter/customer" + currencyx "github.com/openmeterio/openmeter/pkg/currencyx" + models "github.com/openmeterio/openmeter/pkg/models" + "time" +) + +func init() { + ConvertCreateCustomerRequest = func(context string, source v3.CreateCustomerRequest) customer.CreateCustomerInput { + var customerCreateCustomerInput customer.CreateCustomerInput + customerCreateCustomerInput.Namespace = NamespaceFromContext(context) + customerCreateCustomerInput.CustomerMutate = ConvertCreateCustomerToCustomerMutate(source) + return customerCreateCustomerInput + } + ConvertCreateCustomerToCustomerMutate = func(source v3.CreateCustomerRequest) customer.CustomerMutate { + var customerCustomerMutate customer.CustomerMutate + pString := source.Key + customerCustomerMutate.Key = &pString + customerCustomerMutate.Name = source.Name + customerCustomerMutate.Description = source.Description + customerCustomerMutate.UsageAttribution = pV3BillingCustomerUsageAttributionToCustomerCustomerUsageAttribution(source.UsageAttribution) + customerCustomerMutate.PrimaryEmail = source.PrimaryEmail + if source.Currency != nil { + currencyxCode := currencyx.Code(*source.Currency) + customerCustomerMutate.Currency = ¤cyxCode + } + customerCustomerMutate.BillingAddress = pV3BillingAddressToPModelsAddress(source.BillingAddress) + customerCustomerMutate.Metadata = pV3LabelsToPModelsMetadata(source.Labels) + return customerCustomerMutate + } + ConvertCustomer = func(source customer.Customer) v3.BillingCustomer { + var v3BillingCustomer v3.BillingCustomer + v3BillingCustomer.BillingAddress = pModelsAddressToPV3BillingAddress(source.BillingAddress) + v3BillingCustomer.CreatedAt = timeTimeToPTimeTime(source.ManagedResource.ManagedModel.CreatedAt) + if source.Currency != nil { + xstring := string(*source.Currency) + v3BillingCustomer.Currency = &xstring + } + v3BillingCustomer.DeletedAt = source.ManagedResource.ManagedModel.DeletedAt + v3BillingCustomer.Description = source.ManagedResource.Description + v3BillingCustomer.Id = source.ManagedResource.ID + if source.Key != nil { + v3BillingCustomer.Key = *source.Key + } + v3BillingCustomer.Labels = pModelsMetadataToPV3Labels(source.Metadata) + v3BillingCustomer.Name = source.ManagedResource.Name + v3BillingCustomer.PrimaryEmail = source.PrimaryEmail + v3BillingCustomer.UpdatedAt = timeTimeToPTimeTime(source.ManagedResource.ManagedModel.UpdatedAt) + v3BillingCustomer.UsageAttribution = customerCustomerUsageAttributionToPV3BillingCustomerUsageAttribution(source.UsageAttribution) + return v3BillingCustomer + } + ConvertCustomerListResponse = func(source response.CursorPaginationResponse[customer.Customer]) v3.CustomerPaginatedResponse { + var v3CustomerPaginatedResponse v3.CustomerPaginatedResponse + if source.Data != nil { + v3CustomerPaginatedResponse.Data = make([]v3.BillingCustomer, len(source.Data)) + for i := 0; i < len(source.Data); i++ { + v3CustomerPaginatedResponse.Data[i] = ConvertCustomer(source.Data[i]) + } + } + v3CustomerPaginatedResponse.Meta = responseCursorMetaToV3CursorMeta(source.Meta) + return v3CustomerPaginatedResponse + } +} +func customerCustomerUsageAttributionToPV3BillingCustomerUsageAttribution(source customer.CustomerUsageAttribution) *v3.BillingCustomerUsageAttribution { + var v3BillingCustomerUsageAttribution v3.BillingCustomerUsageAttribution + v3BillingCustomerUsageAttribution.SubjectKeys = source.SubjectKeys + return &v3BillingCustomerUsageAttribution +} +func modelsMetadataToV3Labels(source models.Metadata) v3.Labels { + var v3Labels v3.Labels + if source != nil { + v3Labels = make(v3.Labels, len(source)) + for key, value := range source { + v3Labels[key] = value + } + } + return v3Labels +} +func nullableNullableToNullableNullable(source nullable.Nullable[string]) nullable.Nullable[string] { + return source +} +func pModelsAddressToPV3BillingAddress(source *models.Address) *v3.BillingAddress { + var pV3BillingAddress *v3.BillingAddress + if source != nil { + var v3BillingAddress v3.BillingAddress + v3BillingAddress.City = (*source).City + if (*source).Country != nil { + xstring := string(*(*source).Country) + v3BillingAddress.Country = &xstring + } + v3BillingAddress.Line1 = (*source).Line1 + v3BillingAddress.Line2 = (*source).Line2 + v3BillingAddress.PhoneNumber = (*source).PhoneNumber + v3BillingAddress.PostalCode = (*source).PostalCode + v3BillingAddress.State = (*source).State + pV3BillingAddress = &v3BillingAddress + } + return pV3BillingAddress +} +func pModelsMetadataToPV3Labels(source *models.Metadata) *v3.Labels { + var pV3Labels *v3.Labels + if source != nil { + v3Labels := modelsMetadataToV3Labels((*source)) + pV3Labels = &v3Labels + } + return pV3Labels +} +func pV3BillingAddressToPModelsAddress(source *v3.BillingAddress) *models.Address { + var pModelsAddress *models.Address + if source != nil { + var modelsAddress models.Address + if (*source).Country != nil { + modelsCountryCode := models.CountryCode(*(*source).Country) + modelsAddress.Country = &modelsCountryCode + } + modelsAddress.PostalCode = (*source).PostalCode + modelsAddress.State = (*source).State + modelsAddress.City = (*source).City + modelsAddress.Line1 = (*source).Line1 + modelsAddress.Line2 = (*source).Line2 + modelsAddress.PhoneNumber = (*source).PhoneNumber + pModelsAddress = &modelsAddress + } + return pModelsAddress +} +func pV3BillingCustomerUsageAttributionToCustomerCustomerUsageAttribution(source *v3.BillingCustomerUsageAttribution) customer.CustomerUsageAttribution { + var customerCustomerUsageAttribution customer.CustomerUsageAttribution + if source != nil { + customerCustomerUsageAttribution.SubjectKeys = (*source).SubjectKeys + } + return customerCustomerUsageAttribution +} +func pV3LabelsToPModelsMetadata(source *v3.Labels) *models.Metadata { + var pModelsMetadata *models.Metadata + if source != nil { + modelsMetadata := v3LabelsToModelsMetadata((*source)) + pModelsMetadata = &modelsMetadata + } + return pModelsMetadata +} +func responseCursorMetaPageToV3CursorMetaPage(source response.CursorMetaPage) v3.CursorMetaPage { + var v3CursorMetaPage v3.CursorMetaPage + v3CursorMetaPage.First = source.First + v3CursorMetaPage.Last = source.Last + v3CursorMetaPage.Next = nullableNullableToNullableNullable(source.Next) + v3CursorMetaPage.Previous = nullableNullableToNullableNullable(source.Previous) + v3CursorMetaPage.Size = source.Size + return v3CursorMetaPage +} +func responseCursorMetaToV3CursorMeta(source response.CursorMeta) v3.CursorMeta { + var v3CursorMeta v3.CursorMeta + v3CursorMeta.Page = responseCursorMetaPageToV3CursorMetaPage(source.Page) + return v3CursorMeta +} +func timeTimeToPTimeTime(source time.Time) *time.Time { + return &source +} +func v3LabelsToModelsMetadata(source v3.Labels) models.Metadata { + var modelsMetadata models.Metadata + if source != nil { + modelsMetadata = make(models.Metadata, len(source)) + for key, value := range source { + modelsMetadata[key] = value + } + } + return modelsMetadata +} diff --git a/api/v3/handlers/convert.go b/api/v3/handlers/convert.go new file mode 100644 index 0000000000..08726928f3 --- /dev/null +++ b/api/v3/handlers/convert.go @@ -0,0 +1,51 @@ +//go:generate go tool github.com/jmattheis/goverter/cmd/goverter gen ./ + +package handlers + +import ( + "time" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/pagination/v2" + "github.com/samber/lo" +) + +// goverter:variables +// goverter:skipCopySameType +// goverter:output:file ./convert.gen.go +// goverter:useZeroValueOnPointerInconsistency +// goverter:useUnderlyingTypeMethods +// goverter:matchIgnoreCase +var ( + // goverter:context namespace + // goverter:map Namespace | NamespaceFromContext + // goverter:map . CustomerMutate + ConvertCreateCustomerRequest func(namespace string, createCustomerRequest api.CreateCustomerRequest) customer.CreateCustomerInput + // goverter:map Metadata Labels + // goverter:map ManagedResource.ID Id + // goverter:map ManagedResource.Description Description + // goverter:map ManagedResource.Name Name + // goverter:map ManagedResource.ManagedModel.CreatedAt CreatedAt + // goverter:map ManagedResource.ManagedModel.UpdatedAt UpdatedAt + // goverter:map ManagedResource.ManagedModel.DeletedAt DeletedAt + ConvertCustomer func(customer.Customer) api.BillingCustomer + // goverter:map Labels Metadata + // goverter:ignore Annotation + ConvertCreateCustomerToCustomerMutate func(createCustomerRequest api.CreateCustomerRequest) customer.CustomerMutate + ConvertCustomerListResponse func(customers response.CursorPaginationResponse[customer.Customer]) api.CustomerPaginatedResponse +) + +//goverter:context namespace +func NamespaceFromContext(namespace string) string { + return namespace +} + +type Customer struct { + api.BillingCustomer +} + +func (c Customer) Cursor() pagination.Cursor { + return pagination.NewCursor(lo.FromPtrOr(c.CreatedAt, time.Now()), c.Id) +} diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go new file mode 100644 index 0000000000..7f5dbe7058 --- /dev/null +++ b/api/v3/handlers/customer.go @@ -0,0 +1,176 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + v3 "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/samber/lo" +) + +type CustomerHandler interface { + ListCustomers() ListCustomersHandler + CreateCustomer() CreateCustomerHandler + // DeleteCustomer() DeleteCustomerHandler + GetCustomer() GetCustomerHandler + // UpdateCustomer() UpdateCustomerHandler +} + +type customerHandler struct { + service customer.Service + resolveNamespace func(ctx context.Context) (string, error) + options []httptransport.HandlerOption +} + +func NewCustomerHandler( + resolveNamespace func(ctx context.Context) (string, error), + service customer.Service, + options ...httptransport.HandlerOption, +) CustomerHandler { + return &customerHandler{ + service: service, + resolveNamespace: resolveNamespace, + options: options, + } +} + +type ( + ListCustomersParams = v3.ListCustomersParams + ListCustomersRequest = customer.ListCustomersInput + ListCustomersResponse = response.CursorPaginationResponse[Customer] + ListCustomersHandler httptransport.HandlerWithArgs[ListCustomersRequest, ListCustomersResponse, ListCustomersParams] +) + +func (h *customerHandler) ListCustomers() ListCustomersHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListCustomersParams) (ListCustomersRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListCustomersRequest{}, err + } + + req := ListCustomersRequest{ + Namespace: ns, + + // TODO cursor pagination + } + + return req, nil + }, + func(ctx context.Context, request ListCustomersRequest) (ListCustomersResponse, error) { + resp, err := h.service.ListCustomers(ctx, request) + if err != nil { + return ListCustomersResponse{}, fmt.Errorf("failed to list customers: %w", err) + } + + customers := lo.Map(resp.Items, func(item customer.Customer, _ int) Customer { + return Customer{ + BillingCustomer: ConvertCustomer(item), + } + }) + + // Map the customers to the API + return response.NewCursorPaginationResponse(customers), nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListCustomersResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-customers"), + )..., + ) +} + +type ( + CreateCustomerRequest = customer.CreateCustomerInput + CreateCustomerResponse = api.BillingCustomer + CreateCustomerHandler httptransport.Handler[CreateCustomerRequest, CreateCustomerResponse] +) + +// CreateCustomer returns a new httptransport.Handler for creating a customer. +func (h *customerHandler) CreateCustomer() CreateCustomerHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (CreateCustomerRequest, error) { + body := api.CreateCustomerRequest{} + if err := request.ParseBody(r, &body); err != nil { + return CreateCustomerRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreateCustomerRequest{}, err + } + + req := ConvertCreateCustomerRequest(ns, body) + + return req, nil + }, + func(ctx context.Context, request CreateCustomerRequest) (CreateCustomerResponse, error) { + customer, err := h.service.CreateCustomer(ctx, request) + if err != nil { + return CreateCustomerResponse{}, err + } + + if customer == nil { + return CreateCustomerResponse{}, fmt.Errorf("failed to create customer") + } + + return ConvertCustomer(*customer), nil + }, + commonhttp.JSONResponseEncoderWithStatus[CreateCustomerResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("create-customer"), + )..., + ) +} + +type ( + GetCustomerRequest = customer.GetCustomerInput + GetCustomerResponse = api.BillingCustomer + GetCustomerParams = string + GetCustomerHandler httptransport.HandlerWithArgs[GetCustomerRequest, GetCustomerResponse, GetCustomerParams] +) + +// GetCustomer returns a handler for getting a customer. +func (h *customerHandler) GetCustomer() GetCustomerHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, customerIDOrKey GetCustomerParams) (GetCustomerRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetCustomerRequest{}, err + } + + return GetCustomerRequest{ + CustomerIDOrKey: &customer.CustomerIDOrKey{ + Namespace: ns, + IDOrKey: customerIDOrKey, + }, + }, nil + }, + func(ctx context.Context, request GetCustomerRequest) (GetCustomerResponse, error) { + // Get the customer + cus, err := h.service.GetCustomer(ctx, request) + if err != nil { + return GetCustomerResponse{}, err + } + + if cus == nil { + return GetCustomerResponse{}, fmt.Errorf("failed to get customer") + } + + return ConvertCustomer(*cus), nil + }, + commonhttp.JSONResponseEncoderWithStatus[GetCustomerResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-customer"), + )..., + ) +} diff --git a/api/v3/oasmiddleware/decoder.go b/api/v3/oasmiddleware/decoder.go new file mode 100644 index 0000000000..60e11b895d --- /dev/null +++ b/api/v3/oasmiddleware/decoder.go @@ -0,0 +1,25 @@ +package oasmiddleware + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" +) + +// ref: https://github.com/getkin/kin-openapi/blob/994d4f01c1e8dd613805668a7c10b568547f7789/openapi3filter/req_resp_decoder.go#L1031-L1047 + +// JsonBodyDecoder is meant to be used with openapi3filter.RegisterBodyDecoder +// to register a decoder for a custom vendor type like "application/konnect.foo+json" +func JsonBodyDecoder(body io.Reader, _ http.Header, _ *openapi3.SchemaRef, _ openapi3filter.EncodingFn) (any, error) { + var value any + dec := json.NewDecoder(body) + dec.UseNumber() + if err := dec.Decode(&value); err != nil { + return nil, &openapi3filter.ParseError{Kind: openapi3filter.KindInvalidFormat, Cause: err} + } + + return value, nil +} diff --git a/api/v3/oasmiddleware/error.go b/api/v3/oasmiddleware/error.go new file mode 100644 index 0000000000..be0dc0d1db --- /dev/null +++ b/api/v3/oasmiddleware/error.go @@ -0,0 +1,143 @@ +package oasmiddleware + +import ( + "errors" + "fmt" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +var oasRuleToAip = map[string]string{ + "minLength": "min_length", + "maxLength": "max_length", + "minItems": "min_items", + "maxItems": "max_items", +} + +func ToAipError(me openapi3.MultiError) []apierrors.InvalidParameter { + return aipMapper(me, nil) +} + +func aipMapper(me openapi3.MultiError, parent *apierrors.InvalidParameter) []apierrors.InvalidParameter { + var ipErrs []apierrors.InvalidParameter + for _, err := range me { + var i *apierrors.InvalidParameter + if parent != nil { + i = parent + } else { + i = &apierrors.InvalidParameter{} + } + switch err := err.(type) { + case *openapi3.SchemaError: + i.Reason = err.Reason + ipErrs = append(ipErrs, invalidParamFromSchemaError(err, i)) + case *openapi3filter.RequestError: + if err.Parameter != nil { + if err.Parameter.Name != "" { + i.Field = err.Parameter.Name + } + if err.Parameter.In != "" { + i.Source = apierrors.ToInvalid(err.Parameter.In) + } + if err.Parameter.Required { + i.Rule = "required" + } + } + i.Reason = err.Reason + if err.Reason == "" || err.RequestBody != nil { + i.Reason = err.Error() + } + + if err, ok := err.Err.(openapi3.MultiError); ok { + ipErrs = append(ipErrs, aipMapper(err, i)...) + continue + } + + if err, ok := err.Err.(*openapi3.SchemaError); ok { + i.Choices = make([]string, 0) + if err.SchemaField == "enum" { + i.Rule = "enum" + for _, v := range err.Schema.Enum { + i.Choices = append(i.Choices, fmt.Sprintf("%v", v)) + } + i.Reason = fmt.Sprintf("must be one of: [%s]", strings.Join(i.Choices, ",")) + } else if err.SchemaField == "oneOf" { + ipErrs = append(ipErrs, collectFromSchemaError(err)...) + continue + } + } + ipErrs = append(ipErrs, *i) + } + } + return ipErrs +} + +// collectFromSchemaError looks at schemaErr.Origin. If there are deeper +// child errors (via unwrapOriginError), it returns those. Otherwise, it +// returns a single InvalidParameter built from schemaErr itself. +func collectFromSchemaError(se *openapi3.SchemaError) []apierrors.InvalidParameter { + childParams := unwrapOriginError(se) + if len(childParams) == 0 { + return []apierrors.InvalidParameter{ + invalidParamFromSchemaError(se, nil), + } + } + return childParams +} + +// unwrapOriginError traverses schemaErr.Origin (which may be a wrapped multiErrorForOneOf) +// and returns a flat slice of InvalidParameter entries for each underlying *SchemaError. +func unwrapOriginError(schemaErr *openapi3.SchemaError) []apierrors.InvalidParameter { + if schemaErr == nil || schemaErr.Origin == nil { + return nil + } + + // 1) First, try to pull out a MultiError (or multiErrorForOneOf) from the wrapper chain. + var me openapi3.MultiError + if errors.As(schemaErr.Origin, &me) { + var result []apierrors.InvalidParameter + for _, subErr := range me { + var subSE *openapi3.SchemaError + if errors.As(subErr, &subSE) { + result = append(result, collectFromSchemaError(subSE)...) + } + } + return result + } + + // 2) If there are no multi-errors and Origin wraps another *SchemaError somewhere in its chain, dive into that. + var innerSE *openapi3.SchemaError + if errors.As(schemaErr.Origin, &innerSE) { + return collectFromSchemaError(innerSE) + } + + // 3) If we reach here, Origin was neither a nested *SchemaError nor a MultiError. + return nil +} + +func invalidParamFromSchemaError( + schemaErr *openapi3.SchemaError, + parent *apierrors.InvalidParameter, +) apierrors.InvalidParameter { + var ip *apierrors.InvalidParameter + if parent != nil { + ip = parent + } else { + ip = &apierrors.InvalidParameter{ + Reason: schemaErr.Reason, + } + } + if rule, ok := oasRuleToAip[schemaErr.SchemaField]; ok { + ip.Rule = rule + } else { + ip.Rule = schemaErr.SchemaField + } + if path := schemaErr.JSONPointer(); len(path) > 0 { + ip.Field = strings.Join(path, ".") + } + return *ip +} diff --git a/api/v3/oasmiddleware/hook.go b/api/v3/oasmiddleware/hook.go new file mode 100644 index 0000000000..f28e225772 --- /dev/null +++ b/api/v3/oasmiddleware/hook.go @@ -0,0 +1,106 @@ +package oasmiddleware + +import ( + "errors" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +var ErrRouteNotFound = errors.New("route not found") + +// OasRouteNotFoundErrorHook handles the error when a route is not found in a validation +// router. This will stop the request lifecycle and return an AIP compliant 404 response +func OasRouteNotFoundErrorHook(err error, w http.ResponseWriter, r *http.Request) bool { + if err != nil { + apierrors. + NewNotFoundError(r.Context(), ErrRouteNotFound, "route"). + HandleAPIError(w, r) + return true + } + return false +} + +// OasValidationErrorHook handles the error when a request is not matching the +// OAS spec definition for a given route in the validation router. +// This will stop the request lifecycle and return an AIP compliant 400 response +func OasValidationErrorHook(err error, w http.ResponseWriter, r *http.Request) bool { + ctx := r.Context() + switch err := err.(type) { + case nil: + return false + case openapi3.MultiError: + invalidParams := ToAipError(err) + sourcePath := false + for _, v := range invalidParams { + if v.Source == apierrors.InvalidParamSourcePath { + sourcePath = true + break + } + } + if sourcePath { + apierrors. + NewNotFoundError(ctx, err, "entity"). + HandleAPIError(w, r) + } else { + apierrors. + NewBadRequestError(ctx, SanitizeSensitiveFieldValues(err), ToAipError(err)). + HandleAPIError(w, r) + } + return true + case *openapi3filter.RequestError: + if err.Parameter.In == "path" { + apierrors. + NewNotFoundError(ctx, err, "entity"). + HandleAPIError(w, r) + return true + } + } + apierrors. + NewBadRequestError(ctx, err, nil). + HandleAPIError(w, r) + return true +} + +func SanitizeSensitiveFieldValues(err error) error { + switch err := err.(type) { + case nil: + return nil + case openapi3.MultiError: + sanitizedMultiErr := make(openapi3.MultiError, 0) + for _, vErr := range err { + sanitizedMultiErr = append(sanitizedMultiErr, SanitizeSensitiveFieldValues(vErr)) + } + return sanitizedMultiErr + case *openapi3filter.RequestError: + err.Err = SanitizeSensitiveFieldValues(err.Err) + return err + case *openapi3.SchemaError: + if err.Schema != nil && err.Schema.Extensions != nil { + xSensitive, ok := err.Schema.Extensions["x-sensitive"] + if ok && isSensitive(xSensitive) { + err.Value = "********" + } + } + return err + default: + return err + } +} + +func isSensitive(sensitive any) bool { + switch v := sensitive.(type) { + case string: + if v == "true" { + return true + } + return false + case bool: + return v + default: + return false + } +} diff --git a/api/v3/oasmiddleware/response.go b/api/v3/oasmiddleware/response.go new file mode 100644 index 0000000000..35fa28b8db --- /dev/null +++ b/api/v3/oasmiddleware/response.go @@ -0,0 +1,56 @@ +package oasmiddleware + +import ( + "bytes" + "net/http" +) + +// ResponseWriterWrapper leverage to the original response write to be able to access +// the written body and the status code of the response. This is used in the response middleware +// but can also be used in a logging middleware to log the status code of the response. +type ResponseWriterWrapper struct { + w *http.ResponseWriter + body *bytes.Buffer + statusCode *int +} + +func (rww ResponseWriterWrapper) Body() *bytes.Buffer { + return rww.body +} + +func (rww ResponseWriterWrapper) StatusCode() *int { + return rww.statusCode +} + +func NewResponseWriterWrapper(w http.ResponseWriter) ResponseWriterWrapper { + var ( + buf bytes.Buffer + statusCode = 200 + ) + return ResponseWriterWrapper{ + w: &w, + body: &buf, + statusCode: &statusCode, + } +} + +// Write function overwrites the http.ResponseWriter Header() function +func (rww ResponseWriterWrapper) Write(buf []byte) (int, error) { + rww.body.Write(buf) + return (*rww.w).Write(buf) +} + +// Header function overwrites the http.ResponseWriter Header() function +func (rww ResponseWriterWrapper) Header() http.Header { + return (*rww.w).Header() +} + +// WriteHeader function overwrites the http.ResponseWriter WriteHeader() function +func (rww ResponseWriterWrapper) WriteHeader(statusCode int) { + *rww.statusCode = statusCode + (*rww.w).WriteHeader(statusCode) +} + +func (rww ResponseWriterWrapper) Unwrap() http.ResponseWriter { + return *rww.w +} diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index da67a9cc31..70bd32bdc0 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -17,16 +17,108 @@ servers: description: Local variables: {} tags: + - name: Billing Customers + description: Customers are used to track usage of your product or service. Customers can be individuals or organizations that can subscribe to plans and have access to features. - name: Metering Events description: Metering events are used to track usage of your product or service. Events are processed asynchronously by the meters, so they may not be immediately available for querying. paths: - /metering/events: + /openmeter/customers: + post: + operationId: create-customer + summary: Create customer + responses: + '201': + description: Customer created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomer' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/NotAvailable' + tags: + - Billing Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCustomerRequest' + x-internal: true + x-unstable: true + get: + operationId: list-customers + summary: List customers + parameters: + - $ref: '#/components/parameters/CursorPageQuery' + responses: + '200': + description: Cursor paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerPaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/NotAvailable' + tags: + - Billing Customers + x-internal: true + x-unstable: true + /openmeter/customers/{customerId}: + get: + operationId: get-customer + summary: Get customer + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Customer response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomer' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/NotAvailable' + tags: + - Billing Customers + x-internal: true + x-unstable: true + /openmeter/events: post: operationId: ingest-metering-events description: Ingests an event or batch of events following the CloudEvents specification. summary: Ingest metering events responses: - '204': + '202': description: The events have been ingested and are being processed asynchronously. '400': $ref: '#/components/responses/BadRequest' @@ -62,6 +154,209 @@ paths: $ref: '#/components/schemas/MeteringEvent' components: schemas: + BillingAddress: + type: object + properties: + country: + allOf: + - $ref: '#/components/schemas/CountryCode' + description: Country code in [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) alpha-2 format. + title: Country + postal_code: + type: string + description: Postal code. + title: Postal Code + state: + type: string + description: State or province. + title: State + city: + type: string + description: City. + title: City + line1: + type: string + description: First line of the address. + title: Line 1 + line2: + type: string + description: Second line of the address. + title: Line 2 + phone_number: + type: string + description: Phone number. + title: Phone Number + description: Address + BillingCustomer: + type: object + required: + - id + - name + - key + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + key: + $ref: '#/components/schemas/ResourceKey' + usage_attribution: + allOf: + - $ref: '#/components/schemas/BillingCustomerUsageAttribution' + description: Mapping to attribute metered usage to the customer by the event subject. + title: Usage Attribution + primary_email: + type: string + description: The primary email address of the customer. + title: Primary Email + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: |- + Currency of the customer. + Used for billing, tax and invoicing. + title: Currency + billing_address: + allOf: + - $ref: '#/components/schemas/BillingAddress' + description: |- + The billing address of the customer. + Used for tax and invoicing. + title: Billing Address + description: Customers can be individuals or organizations that can subscribe to plans and have access to features. + BillingCustomerUsageAttribution: + type: object + required: + - subject_keys + properties: + subject_keys: + type: array + items: + $ref: '#/components/schemas/Customers.UsageAttributionKey' + minItems: 0 + description: |- + The subjects that are attributed to the customer. + Can be empty when no usage event subjects are associated with the customer. + title: Subject Keys + description: |- + Mapping to attribute metered usage to the customer. + One customer can have zero or more subjects, + but one subject can only belong to one customer. + CountryCode: + type: string + minLength: 2 + maxLength: 2 + pattern: ^[A-Z]{2}$ + description: |- + [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) alpha-2 country code. + Custom two-letter country codes are also supported for convenience. + example: US + CreateCustomerRequest: + type: object + required: + - name + - key + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + $ref: '#/components/schemas/ResourceKey' + usage_attribution: + allOf: + - $ref: '#/components/schemas/BillingCustomerUsageAttribution' + description: Mapping to attribute metered usage to the customer by the event subject. + title: Usage Attribution + primary_email: + type: string + description: The primary email address of the customer. + title: Primary Email + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: |- + Currency of the customer. + Used for billing, tax and invoicing. + title: Currency + billing_address: + allOf: + - $ref: '#/components/schemas/BillingAddress' + description: |- + The billing address of the customer. + Used for tax and invoicing. + title: Billing Address + description: Customer create request. + 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 + CustomerPaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingCustomer' + meta: + $ref: '#/components/schemas/CursorMeta' + description: Cursor paginated response. + Customers.UsageAttributionKey: + type: string + minLength: 1 DateTime: type: string format: date-time @@ -145,6 +440,87 @@ components: tokens: 100 model: gpt-4o type: input + 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 + CursorPageParameters: + type: object + properties: + size: + type: integer + description: The number of items included per page. + example: 10 + after: + type: string + description: Cursor param specifying the page (i.e. the next page) of data returned. + example: ewogICJpZCI6ICJoZWxsbyB3b3JsZCIKfQ + before: + type: string + description: Cursor param specifying the page (i.e. the previous page) of data returned. + example: ewogICJpZCI6ICJoZWxsbyB3b3JsZCIKfQ + Labels: + title: Labels + type: object + example: + env: test + maxProperties: 50 + description: | + Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + + Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + additionalProperties: + type: string + pattern: ^[a-z0-9A-Z]{1}([a-z0-9A-Z-._]*[a-z0-9A-Z]+)?$ + minLength: 1 + maxLength: 63 + CursorMetaPage: + type: object + required: + - size + - next + - previous + properties: + first: + description: URI to the first page + type: string + format: path + last: + description: URI to the last page + type: string + format: path + next: + description: URI to the next page + type: string + format: path + nullable: true + previous: + description: URI to the previous page + type: string + format: path + nullable: true + size: + description: Requested page size + type: number + example: 10 + CursorMeta: + type: object + description: Pagination metadata. + required: + - page + properties: + page: + $ref: '#/components/schemas/CursorMetaPage' BaseError: type: object title: Error @@ -422,6 +798,29 @@ components: example: kong:trace:1234567890 detail: example: Forbidden + NotFoundError: + allOf: + - $ref: '#/components/schemas/BaseError' + - type: object + properties: + status: + example: 404 + title: + example: Not Found + type: + example: https://httpstatuses.com/404 + instance: + example: kong:trace:1234567890 + detail: + example: Not found + parameters: + CursorPageQuery: + name: page + description: Determines which page of the collection to retrieve. + required: false + in: query + schema: + $ref: '#/components/schemas/CursorPageParameters' responses: BadRequest: description: Bad Request @@ -459,6 +858,15 @@ components: application/problem+json: schema: $ref: '#/components/schemas/BaseError' + NotFound: + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/NotFoundError' + examples: + NotFoundExample: + $ref: '#/components/examples/NotFoundExample' examples: UnauthorizedExample: value: @@ -472,3 +880,9 @@ components: title: Forbidden instance: kong:trace:2723154947768991354 detail: You do not have permission to perform this action + NotFoundExample: + value: + status: 404 + title: Not Found + instance: kong:trace:6816496025408232265 + detail: Not Found diff --git a/api/v3/render/render.go b/api/v3/render/render.go new file mode 100644 index 0000000000..de355d1db6 --- /dev/null +++ b/api/v3/render/render.go @@ -0,0 +1,86 @@ +package render + +import ( + "encoding/json" + "net/http" + + "github.com/invopop/yaml" +) + +const ( + ContentTypeKey = "Content-Type" + ContentTypeJSONValue = "application/json" + ContentTypeYAMLValue = "application/yaml" + defaultStatusCode = http.StatusOK +) + +type config struct { + statusCode int +} + +type Option func(w http.ResponseWriter, c *config) + +func WithStatus(status int) Option { + return func(_ http.ResponseWriter, c *config) { + c.statusCode = status + } +} + +func WithHeader(key, value string) Option { + return func(w http.ResponseWriter, _ *config) { + w.Header().Set(key, value) + } +} + +func WithContentType(contentType string) Option { + return WithHeader(ContentTypeKey, contentType) +} + +// RenderJSON renders json object with returned apierrors +func RenderJSON[O any](w http.ResponseWriter, o O, opts ...Option) error { + c := &config{ + statusCode: defaultStatusCode, + } + + for _, opt := range opts { + opt(w, c) + } + + if w.Header().Get(ContentTypeKey) == "" { + w.Header().Set(ContentTypeKey, ContentTypeJSONValue) + } + + w.WriteHeader(c.statusCode) + + body, err := json.Marshal(o) + if err != nil { + return err + } + + _, err = w.Write(body) + return err +} + +func RenderYAML[O any](w http.ResponseWriter, o O, opts ...Option) error { + c := &config{ + statusCode: defaultStatusCode, + } + + for _, opt := range opts { + opt(w, c) + } + + if w.Header().Get(ContentTypeKey) == "" { + w.Header().Set(ContentTypeKey, ContentTypeYAMLValue) + } + + w.WriteHeader(c.statusCode) + + body, err := yaml.Marshal(o) + if err != nil { + return err + } + + _, err = w.Write(body) + return err +} diff --git a/api/v3/request/body.go b/api/v3/request/body.go new file mode 100644 index 0000000000..53dd52ffca --- /dev/null +++ b/api/v3/request/body.go @@ -0,0 +1,24 @@ +package request + +import ( + "encoding/json" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +func ParseBody(r *http.Request, payload any) *apierrors.BaseAPIError { + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&payload); err != nil { + return apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Reason: "unable to parse body", + Source: apierrors.InvalidParamSourceBody, + }, + }, + ) + } + + return nil +} diff --git a/api/v3/request/pagination.go b/api/v3/request/pagination.go new file mode 100644 index 0000000000..8308e5ab53 --- /dev/null +++ b/api/v3/request/pagination.go @@ -0,0 +1,41 @@ +package request + +import ( + "errors" + + "github.com/openmeterio/openmeter/pkg/pagination/v2" +) + +const ( + DefaultPaginationSize = 20 + PageBeforeQuery = "page[before]" + PageAfterQuery = "page[after]" +) + +var ( + ErrCursorPaginationSizeInvalid = errors.New("size must be greater than 0") + ErrCursorPaginationUndefined = errors.New("at least before or after cursor need to be defined") + ErrCursorPaginationRange = errors.New("range pagination not supported, both before and after cursor were defined") +) + +type CursorPagination struct { + Size int + After *pagination.Cursor + Before *pagination.Cursor +} + +func (p *CursorPagination) Validate() error { + if p.Size < 1 { + return ErrCursorPaginationSizeInvalid + } + + if p.After == nil && p.Before == nil { + return ErrCursorPaginationUndefined + } + + if p.After != nil && p.Before != nil { + return ErrCursorPaginationRange + } + + return nil +} diff --git a/api/v3/response/pagination.go b/api/v3/response/pagination.go new file mode 100644 index 0000000000..233b7beba7 --- /dev/null +++ b/api/v3/response/pagination.go @@ -0,0 +1,88 @@ +package response + +import ( + "github.com/oapi-codegen/nullable" + "github.com/openmeterio/openmeter/pkg/pagination/v2" + "github.com/samber/lo" +) + +// CursorMeta Pagination metadata. +type CursorMeta struct { + Page CursorMetaPage `json:"page"` +} + +// CursorMetaPage defines model for CursorMetaPage. +type CursorMetaPage struct { + // First cursor + First *string `json:"first,omitempty"` + + // Last cursor + Last *string `json:"last,omitempty"` + + // Next URI to the next page + Next nullable.Nullable[string] `json:"next"` + + // Previous URI to the previous page + Previous nullable.Nullable[string] `json:"previous"` + + // Size of the requested page + Size float32 `json:"size"` +} + +// CursorPaginationResponse represents the response structure for cursor-based pagination +type CursorPaginationResponse[T any] struct { + // The data returned + Data []T `json:"data"` + + Meta CursorMeta `json:"meta"` +} + +// NewCursorPaginationResponse creates a new pagination response from an ordered list of items. +// T must implement the Item interface for cursor generation. +func NewCursorPaginationResponse[T pagination.Item](items []T) CursorPaginationResponse[T] { + result := CursorPaginationResponse[T]{ + Data: items, + Meta: CursorMeta{ + Page: CursorMetaPage{ + Size: float32(len(items)), + Next: nullable.NewNullNullable[string](), + Previous: nullable.NewNullNullable[string](), + }, + }, + } + + // Generate first and last cursor from the first and last item if there are any items + if len(items) > 0 { + firstItem := items[0] + lastItem := items[len(items)-1] + result.Meta.Page.First = lo.ToPtr(firstItem.Cursor().Encode()) + result.Meta.Page.Last = lo.ToPtr(lastItem.Cursor().Encode()) + } + + return result +} + +// // BuildRequestPathWithCursor generates the request path & querystring with new encoded cursors +// // to be return with the cursor pagination metadata in a paginated endpoint. +// func BuildRequestPathWithCursor(r *http.Request, result *CursorPaginationResponse[any]) (string, error) { +// out := "" + +// rr, err := http.NewRequest(r.Method, r.URL.String(), nil) +// if err != nil { +// return out, err +// } + +// q := rr.URL.Query() +// q.Del(request.PageAfterQuery) +// q.Del(request.PageBeforeQuery) +// if result.Meta.Page.First != nil { +// q.Set(request.PageAfterQuery, *result.Meta.Page.First) +// } +// if result.Meta.Page.Last != nil { +// q.Set(request.PageBeforeQuery, *result.Meta.Page.Last) +// } + +// rr.URL.RawQuery = q.Encode() + +// return fmt.Sprintf("%s?%s", rr.URL.Path, rr.URL.RawQuery), nil +// } diff --git a/api/v3/server/customers.go b/api/v3/server/customers.go new file mode 100644 index 0000000000..68f46b7e50 --- /dev/null +++ b/api/v3/server/customers.go @@ -0,0 +1,19 @@ +package server + +import ( + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" +) + +func (s *Server) CreateCustomer(w http.ResponseWriter, r *http.Request) { + s.customerHandler.CreateCustomer().ServeHTTP(w, r) +} + +func (s *Server) GetCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customerHandler.GetCustomer().With(customerId).ServeHTTP(w, r) +} + +func (s *Server) ListCustomers(w http.ResponseWriter, r *http.Request, params api.ListCustomersParams) { + s.customerHandler.ListCustomers().With(params).ServeHTTP(w, r) +} diff --git a/api/v3/server/events.go b/api/v3/server/events.go new file mode 100644 index 0000000000..ee80777bea --- /dev/null +++ b/api/v3/server/events.go @@ -0,0 +1,12 @@ +package server + +import ( + "errors" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +func (s *Server) IngestMeteringEvents(w http.ResponseWriter, r *http.Request) { + apierrors.NewNotImplementedError(r.Context(), errors.New("not implemented")).HandleAPIError(w, r) +} diff --git a/api/v3/server/server.go b/api/v3/server/server.go new file mode 100644 index 0000000000..f961a6852a --- /dev/null +++ b/api/v3/server/server.go @@ -0,0 +1,109 @@ +package server + +import ( + "context" + "errors" + "log/slog" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/go-chi/chi/v5" + oapimiddleware "github.com/oapi-codegen/nethttp-middleware" + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/handlers" + "github.com/openmeterio/openmeter/api/v3/render" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" +) + +type Config struct { + BaseURL string + NamespaceDecoder namespacedriver.NamespaceDecoder + + // services + CustomerService customer.Service +} + +type Server struct { + *Config + + swagger *openapi3.T + customerHandler handlers.CustomerHandler + middlewares []api.MiddlewareFunc +} + +// Make sure we conform to ServerInterface +var _ api.ServerInterface = (*Server)(nil) + +func NewServer(config *Config) (*Server, error) { + // Get the OpenAPI spec + swagger, err := api.GetSwagger() + if err != nil { + slog.Error("failed to get swagger", "error", err) + return nil, err + } + + // Set the server URL to the base URL to make validation work on the base URL + swagger.Servers = []*openapi3.Server{ + { + URL: config.BaseURL, + }, + } + + middlewares := []api.MiddlewareFunc{oapimiddleware.OapiRequestValidatorWithOptions(swagger, &oapimiddleware.Options{ + // ErrorHandlerWithOpts: func(ctx context.Context, err error, w http.ResponseWriter, r *http.Request, opts oapimiddleware.ErrorHandlerOpts) { + // models.NewStatusProblem(ctx, err, http.StatusBadRequest).Respond(w) + // }, + SilenceServersWarning: true, + Options: openapi3filter.Options{ + // NoOp authenticationFunc as it's handled in another middleware + // this is based on `security` property on OpenAPI Spec + AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, + MultiError: true, + SkipSettingDefaults: false, + // ExcludeRequestQueryParams: true, + }, + })} + + resolveNamespace := func(ctx context.Context) (string, error) { + ns, ok := config.NamespaceDecoder.GetNamespace(ctx) + if !ok { + return "", apierrors.NewInternalError(ctx, errors.New("failed to resolve namespace")) + } + + return ns, nil + } + + customerHandler := handlers.NewCustomerHandler(resolveNamespace, config.CustomerService) + + return &Server{ + Config: config, + swagger: swagger, + middlewares: middlewares, + customerHandler: customerHandler, + }, nil +} + +func (s *Server) RegisterRoutes(r chi.Router) { + r.Route(s.BaseURL, func(r chi.Router) { + // Serve the OpenAPI spec + r.Get("/swagger.json", func(w http.ResponseWriter, r *http.Request) { + render.RenderJSON(w, s.swagger) + }) + + r.Get("/swagger.yaml", func(w http.ResponseWriter, r *http.Request) { + render.RenderYAML(w, s.swagger) + }) + + _ = api.HandlerWithOptions(s, api.ChiServerOptions{ + BaseRouter: r, + Middlewares: s.middlewares, + // ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + // config.RouterConfig.ErrorHandler.HandleContext(r.Context(), err) + // errorHandlerReply(w, r, err) + // }, + }) + }) +} diff --git a/go.mod b/go.mod index 5b48474dda..ca0a699925 100644 --- a/go.mod +++ b/go.mod @@ -523,7 +523,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 k8s.io/klog/v2 v2.130.1 modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/openmeter/server/server.go b/openmeter/server/server.go index 08e3aa9130..563fa003c7 100644 --- a/openmeter/server/server.go +++ b/openmeter/server/server.go @@ -12,9 +12,11 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/go-chi/render" - oapimiddleware "github.com/oapi-codegen/nethttp-middleware" + oapimiddleware "github.com/oapi-codegen/nethttp-middleware" "github.com/openmeterio/openmeter/api" + v3server "github.com/openmeterio/openmeter/api/v3/server" + "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" "github.com/openmeterio/openmeter/openmeter/portal/authenticator" "github.com/openmeterio/openmeter/openmeter/server/router" "github.com/openmeterio/openmeter/pkg/contextx" @@ -91,79 +93,98 @@ func NewServer(config *Config) (*Server, error) { middlewareHook(r) } + // Create the V3 API + staticNamespaceDecoder := namespacedriver.StaticNamespaceDecoder(config.RouterConfig.NamespaceManager.GetDefaultNamespace()) + + v3API, err := v3server.NewServer(&v3server.Config{ + BaseURL: "/api/v3", + CustomerService: config.RouterConfig.Customer, + NamespaceDecoder: staticNamespaceDecoder, + }) + if err != nil { + slog.Error("failed to create v3 API", "error", err) + return nil, err + } + r.Use(middleware.RealIP) r.Use(middleware.RequestID) - r.Use(func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - ctx = contextx.WithAttrs(ctx, server.GetRequestAttributes(r)) - h.ServeHTTP(w, r.WithContext(ctx)) + // Mount the V3 API first, more specific routes to be mounted first + v3API.RegisterRoutes(r) + + r.Group(func(r chi.Router) { + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctx = contextx.WithAttrs(ctx, server.GetRequestAttributes(r)) + + h.ServeHTTP(w, r.WithContext(ctx)) + }) }) - }) - r.Use(server.NewRequestLoggerMiddleware(slog.Default().Handler())) - r.Use(middleware.Recoverer) - if config.RouterConfig.PortalCORSEnabled { - // Enable CORS for portal requests - r.Use(corsHandler(corsOptions{ - AllowedPaths: []string{"/api/v1/portal/meters"}, - Options: cors.Options{ - AllowOriginFunc: func(r *http.Request, origin string) bool { - return true + r.Use(server.NewRequestLoggerMiddleware(slog.Default().Handler())) + r.Use(middleware.Recoverer) + if config.RouterConfig.PortalCORSEnabled { + // Enable CORS for portal requests + r.Use(corsHandler(corsOptions{ + AllowedPaths: []string{"/api/v1/portal/meters"}, + Options: cors.Options{ + AllowOriginFunc: func(r *http.Request, origin string) bool { + return true + }, + AllowedMethods: []string{http.MethodGet, http.MethodOptions}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: true, + MaxAge: 1728000, }, - AllowedMethods: []string{http.MethodGet, http.MethodOptions}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, - AllowCredentials: true, - MaxAge: 1728000, - }, - })) - } - r.Use(render.SetContentType(render.ContentTypeJSON)) - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - models.NewStatusProblem(r.Context(), nil, http.StatusNotFound).Respond(w) - }) - r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { - models.NewStatusProblem(r.Context(), nil, http.StatusMethodNotAllowed).Respond(w) - }) + })) + } + r.Use(render.SetContentType(render.ContentTypeJSON)) + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + models.NewStatusProblem(r.Context(), nil, http.StatusNotFound).Respond(w) + }) + r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { + models.NewStatusProblem(r.Context(), nil, http.StatusMethodNotAllowed).Respond(w) + }) - // Serve the OpenAPI spec - r.Get("/api/swagger.json", func(w http.ResponseWriter, r *http.Request) { - render.JSON(w, r, swagger) - }) + // Serve the OpenAPI spec + r.Get("/api/swagger.json", func(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, swagger) + }) - // Apply route handlers - for _, routeHook := range config.RouterHooks.Routes { - routeHook(r) - } + // Apply route handlers + for _, routeHook := range config.RouterHooks.Routes { + routeHook(r) + } - middlewares := []api.MiddlewareFunc{ - authenticator.NewAuthenticator(config.RouterConfig.Portal, config.RouterConfig.ErrorHandler).NewAuthenticatorMiddlewareFunc(swagger), - oapimiddleware.OapiRequestValidatorWithOptions(swagger, &oapimiddleware.Options{ - ErrorHandler: func(w http.ResponseWriter, message string, statusCode int) { - models.NewStatusProblem(context.Background(), errors.New(message), statusCode).Respond(w) - }, - Options: openapi3filter.Options{ - // Unfortunately, the OpenAPI 3 filter library doesn't support context changes - AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, - SkipSettingDefaults: true, - - // Excluding read-only validation because required and readOnly fields in our Go models are translated to non-nil fields, leading to a zero-value being passed to the API - // The OpenAPI spec says read-only fields SHOULD NOT be sent in requests, so technically it should be fine, hence disabling validation for now to make our life easier - ExcludeReadOnlyValidations: true, + middlewares := []api.MiddlewareFunc{ + authenticator.NewAuthenticator(config.RouterConfig.Portal, config.RouterConfig.ErrorHandler).NewAuthenticatorMiddlewareFunc(swagger), + oapimiddleware.OapiRequestValidatorWithOptions(swagger, &oapimiddleware.Options{ + ErrorHandler: func(w http.ResponseWriter, message string, statusCode int) { + models.NewStatusProblem(context.Background(), errors.New(message), statusCode).Respond(w) + }, + Options: openapi3filter.Options{ + // Unfortunately, the OpenAPI 3 filter library doesn't support context changes + AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, + SkipSettingDefaults: true, + + // Excluding read-only validation because required and readOnly fields in our Go models are translated to non-nil fields, leading to a zero-value being passed to the API + // The OpenAPI spec says read-only fields SHOULD NOT be sent in requests, so technically it should be fine, hence disabling validation for now to make our life easier + ExcludeReadOnlyValidations: true, + }, + }), + } + + middlewares = append(middlewares, config.PostAuthMiddlewares...) + + // Use validator middleware to check requests against the OpenAPI schema + _ = api.HandlerWithOptions(impl, api.ChiServerOptions{ + BaseRouter: r, + Middlewares: middlewares, + ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + config.RouterConfig.ErrorHandler.HandleContext(r.Context(), err) + errorHandlerReply(w, r, err) }, - }), - } - - middlewares = append(middlewares, config.PostAuthMiddlewares...) - - // Use validator middleware to check requests against the OpenAPI schema - _ = api.HandlerWithOptions(impl, api.ChiServerOptions{ - BaseRouter: r, - Middlewares: middlewares, - ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { - config.RouterConfig.ErrorHandler.HandleContext(r.Context(), err) - errorHandlerReply(w, r, err) - }, + }) }) return &Server{ From 452fec87e9386ac40c8d002c4a7492674cdc3300 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:32:18 +0100 Subject: [PATCH 02/18] feat(api): meter routes --- api/spec/src/v3/customers/operations.tsp | 31 ++ api/spec/src/v3/konnect.tsp | 6 + api/spec/src/v3/meters/operations.tsp | 2 +- api/spec/src/v3/openmeter.tsp | 15 +- api/spec/src/v3/shared/consts.tsp | 1 + api/spec/src/v3/shared/request.tsp | 12 + api/spec/src/v3/shared/responses.tsp | 20 + api/v3/api.gen.go | 588 ++++++++++++++++++++--- api/v3/handlers/convert.gen.go | 64 +++ api/v3/handlers/convert.go | 22 + api/v3/handlers/customer.go | 5 +- api/v3/handlers/meters.go | 89 ++++ api/v3/openapi.yaml | 465 ++++++++++++++++++ api/v3/response/pagination.go | 1 - api/v3/server/customers.go | 14 + api/v3/server/meters.go | 21 + api/v3/server/server.go | 5 + openmeter/server/server.go | 1 + 18 files changed, 1275 insertions(+), 87 deletions(-) create mode 100644 api/v3/handlers/meters.go create mode 100644 api/v3/server/meters.go diff --git a/api/spec/src/v3/customers/operations.tsp b/api/spec/src/v3/customers/operations.tsp index 2c138dd8f1..afee232901 100644 --- a/api/spec/src/v3/customers/operations.tsp +++ b/api/spec/src/v3/customers/operations.tsp @@ -41,4 +41,35 @@ interface CustomersOperations { list( ...Common.CursorPageQuery, ): Shared.CursorPaginatedResponse | Common.ErrorResponses; + + @put + @operationId("upsert-customer") + @summary("Upsert customer") + @extension(Shared.UnstableExtension, true) + @extension(Shared.InternalExtension, true) + upsert( + @path customerId: Shared.ULID, + @body + customer: Shared.UpsertRequest, + ): Shared.UpsertResponse | Common.NotFound | Common.ErrorResponses; + + @patch + @operationId("update-customer") + @summary("Update customer") + @extension(Shared.UnstableExtension, true) + @extension(Shared.InternalExtension, true) + update( + @path customerId: Shared.ULID, + @body + customer: Shared.UpdateRequest, + ): Shared.UpdateResponse | Common.NotFound | Common.ErrorResponses; + + @delete + @operationId("delete-customer") + @summary("Delete customer") + @extension(Shared.UnstableExtension, true) + @extension(Shared.InternalExtension, true) + delete( + @path customerId: Shared.ULID, + ): Shared.DeleteResponse | Common.NotFound | Common.ErrorResponses; } diff --git a/api/spec/src/v3/konnect.tsp b/api/spec/src/v3/konnect.tsp index 922fb4d310..b87da58be9 100644 --- a/api/spec/src/v3/konnect.tsp +++ b/api/spec/src/v3/konnect.tsp @@ -28,6 +28,7 @@ using TypeSpec.OpenAPI; @server("https://global.api.konghq.com/v3", "Global Production region") @tagMetadata(Shared.EventsTag, #{ description: Shared.EventsDescription }) @tagMetadata(Shared.CustomersTag, #{ description: Shared.CustomersDescription }) +@tagMetadata(Shared.MetersTag, #{ description: Shared.MetersDescription }) @useAuth(systemAccountAccessToken | personalAccessToken | konnectAccessToken) namespace MeteringAndBilling; @@ -41,6 +42,11 @@ interface EventsEndpoints extends Events.EventsOperations {} @friendlyName("${Shared.MeteringAndBillingTitle}: ${Shared.CustomersTag}") interface CustomersEndpoints extends Customers.CustomersOperations {} +@route("/openmeter/meters") +@tag(Shared.MetersTag) +@friendlyName("${Shared.MeteringAndBillingTitle}: ${Shared.MetersTag}") +interface MetersEndpoints extends Meters.MetersOperations {} + // @route("/openmeter/customers/{customerId}/subscriptions") // @tag(Shared.CustomersTag) // @tag(Shared.SubscriptionsTag) diff --git a/api/spec/src/v3/meters/operations.tsp b/api/spec/src/v3/meters/operations.tsp index 049295f767..26a59bea2b 100644 --- a/api/spec/src/v3/meters/operations.tsp +++ b/api/spec/src/v3/meters/operations.tsp @@ -26,7 +26,7 @@ interface MetersOperations { @summary("Get meter") get( @path meterId: Shared.ULID, - ): Shared.GetResponse | Common.ErrorResponses; + ): Shared.GetResponse | Common.NotFound | Common.ErrorResponses; @get @operationId("list-meters") diff --git a/api/spec/src/v3/openmeter.tsp b/api/spec/src/v3/openmeter.tsp index 4c607693f4..847e8abd75 100644 --- a/api/spec/src/v3/openmeter.tsp +++ b/api/spec/src/v3/openmeter.tsp @@ -2,9 +2,10 @@ import "@typespec/http"; import "@typespec/rest"; import "@typespec/openapi"; import "@typespec/openapi3"; -import "./shared/index.tsp"; -import "./events/index.tsp"; import "./customers/index.tsp"; +import "./events/index.tsp"; +import "./meters/index.tsp"; +import "./shared/index.tsp"; import "./subscriptions/index.tsp"; using TypeSpec.Http; @@ -27,18 +28,24 @@ using TypeSpec.OpenAPI; @server("https://openmeter.cloud/api/v3", "Cloud") @tagMetadata(Shared.EventsTag, #{ description: Shared.EventsDescription }) @tagMetadata(Shared.CustomersTag, #{ description: Shared.CustomersDescription }) +@tagMetadata(Shared.MetersTag, #{ description: Shared.MetersDescription }) namespace OpenMeter; @route("/openmeter/events") @tag(Shared.EventsTag) -@friendlyName("OpenMeter: ${Shared.EventsTag}") +@friendlyName("${Shared.OpenMeterTitle}: ${Shared.EventsTag}") interface EventsEndpoints extends Events.EventsOperations {} @route("/openmeter/customers") @tag(Shared.CustomersTag) -@friendlyName("OpenMeter: ${Shared.CustomersTag}") +@friendlyName("${Shared.OpenMeterTitle}: ${Shared.CustomersTag}") interface CustomersEndpoints extends Customers.CustomersOperations {} +@route("/openmeter/meters") +@tag(Shared.MetersTag) +@friendlyName("${Shared.OpenMeterTitle}: ${Shared.MetersTag}") +interface MetersEndpoints extends Meters.MetersOperations {} + // @route("/openmeter/customers/{customerId}/subscriptions") // @tag(Shared.CustomersTag) // @tag(Shared.SubscriptionsTag) diff --git a/api/spec/src/v3/shared/consts.tsp b/api/spec/src/v3/shared/consts.tsp index bd8b2cc335..59d4b4a2c9 100644 --- a/api/spec/src/v3/shared/consts.tsp +++ b/api/spec/src/v3/shared/consts.tsp @@ -1,6 +1,7 @@ namespace Shared; const MeteringAndBillingTitle = "Metering & Billing"; +const OpenMeterTitle = "OpenMeter"; const MetersTag = "Meters"; const MetersDescription = "Meters specify how to aggregate events for billing and analytics purposes. Meters can be configured with multiple aggregation methods and groupings. Multiple meters can be created for the same event type, enabling flexible metering scenarios."; diff --git a/api/spec/src/v3/shared/request.tsp b/api/spec/src/v3/shared/request.tsp index 3caef6b1b1..91528efad5 100644 --- a/api/spec/src/v3/shared/request.tsp +++ b/api/spec/src/v3/shared/request.tsp @@ -1,6 +1,18 @@ +import "@typespec/rest"; + namespace Shared; @doc("{name} create request.", T) @friendlyName("Create{name}Request", T) @withVisibility(Lifecycle.Create) model CreateRequest is DefaultKeyVisibility; + +@doc("{name} upsert request.", T) +@friendlyName("Upsert{name}Request", T) +@withVisibility(Lifecycle.Create, Lifecycle.Update) +model UpsertRequest is DefaultKeyVisibility; + +@doc("{name} update request.", T) +@friendlyName("Update{name}Request", T) +@withVisibility(Lifecycle.Update) +model UpdateRequest is OptionalProperties>>; diff --git a/api/spec/src/v3/shared/responses.tsp b/api/spec/src/v3/shared/responses.tsp index 289ee16ac8..2a7dd6c2e5 100644 --- a/api/spec/src/v3/shared/responses.tsp +++ b/api/spec/src/v3/shared/responses.tsp @@ -18,6 +18,26 @@ model CreateResponse { @Http.body body: T; } +@doc("{name} upsert response.", T) +@friendlyName("Upsert{name}Response", T) +model UpsertResponse { + @Http.statusCode _: 200; + @Http.body body: T; +} + +@doc("{name} updated response.", T) +@friendlyName("Update{name}Response", T) +model UpdateResponse { + @Http.statusCode _: 200; + @Http.body body: T; +} + +@doc("Deleted response.") +@friendlyName("DeleteResponse") +model DeleteResponse { + @Http.statusCode _: 204; +} + @doc("Cursor paginated response.") @friendlyName("{name}PaginatedResponse", T) model CursorPaginatedResponse { diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index 7a60ad4ba1..78f713728a 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -75,6 +75,17 @@ const ( InvalidRulesUnknownProperty InvalidRules = "unknown_property" ) +// Defines values for MeterAggregation. +const ( + MeterAggregationAvg MeterAggregation = "avg" + MeterAggregationCount MeterAggregation = "count" + MeterAggregationLatest MeterAggregation = "latest" + MeterAggregationMax MeterAggregation = "max" + MeterAggregationMin MeterAggregation = "min" + MeterAggregationSum MeterAggregation = "sum" + MeterAggregationUniqueCount MeterAggregation = "unique_count" +) + // Defines values for MeteringEventDatacontenttype. const ( MeteringEventDatacontenttypeApplicationjson MeteringEventDatacontenttype = "application/json" @@ -252,6 +263,50 @@ type CreateCustomerRequest struct { UsageAttribution *BillingCustomerUsageAttribution `json:"usage_attribution,omitempty"` } +// CreateMeterRequest Meter create request. +type CreateMeterRequest struct { + // Aggregation The aggregation type to use for the meter. + Aggregation MeterAggregation `json:"aggregation"` + + // Description Optional description of the resource. + // + // Maximum 1024 characters. + Description *string `json:"description,omitempty"` + + // Dimensions Named JSONPath expressions to extract the group by values from the event data. + // + // Keys must be unique and consist only alphanumeric and underscore characters. + Dimensions *map[string]string `json:"dimensions,omitempty"` + + // EventFrom The date since the meter should include events. + // Useful to skip old events. + // If not specified, all historical events are included. + EventFrom *DateTime `json:"event_from,omitempty"` + + // EventTypeFilter The event type to include in the aggregation. + EventTypeFilter string `json:"event_type_filter"` + + // Key A key is a unique string that is used to identify a resource. + Key ResourceKey `json:"key"` + + // Labels Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + // + // Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + Labels *Labels `json:"labels,omitempty"` + + // Name Display name of the resource. + // + // Between 1 and 256 characters. + Name string `json:"name"` + + // ValueProperty JSONPath expression to extract the value from the ingested event's data property. + // + // The ingested value for sum, avg, min, and max aggregations is a number or a string that can be parsed to a number. + // + // For unique_count aggregation, the ingested value must be a string. For count aggregation the value_property is ignored. + ValueProperty *string `json:"value_property,omitempty"` +} + // 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 @@ -396,6 +451,71 @@ type InvalidRules string // Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". type Labels map[string]string +// Meter A meter is a configuration that defines how to match and aggregate events. +type Meter struct { + // Aggregation The aggregation type to use for the meter. + Aggregation MeterAggregation `json:"aggregation"` + + // CreatedAt An ISO-8601 timestamp representation of entity creation date. + CreatedAt *DateTime `json:"created_at,omitempty"` + + // DeletedAt An ISO-8601 timestamp representation of entity deletion date. + DeletedAt *DateTime `json:"deleted_at,omitempty"` + + // Description Optional description of the resource. + // + // Maximum 1024 characters. + Description *string `json:"description,omitempty"` + + // Dimensions Named JSONPath expressions to extract the group by values from the event data. + // + // Keys must be unique and consist only alphanumeric and underscore characters. + Dimensions *map[string]string `json:"dimensions,omitempty"` + + // EventFrom The date since the meter should include events. + // Useful to skip old events. + // If not specified, all historical events are included. + EventFrom *DateTime `json:"event_from,omitempty"` + + // EventTypeFilter The event type to include in the aggregation. + EventTypeFilter string `json:"event_type_filter"` + Id ULID `json:"id"` + + // Key A key is a unique string that is used to identify a resource. + Key ResourceKey `json:"key"` + + // Labels Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + // + // Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + Labels *Labels `json:"labels,omitempty"` + + // Name Display name of the resource. + // + // Between 1 and 256 characters. + Name string `json:"name"` + + // UpdatedAt An ISO-8601 timestamp representation of entity last update date. + UpdatedAt *DateTime `json:"updated_at,omitempty"` + + // ValueProperty JSONPath expression to extract the value from the ingested event's data property. + // + // The ingested value for sum, avg, min, and max aggregations is a number or a string that can be parsed to a number. + // + // For unique_count aggregation, the ingested value must be a string. For count aggregation the value_property is ignored. + ValueProperty *string `json:"value_property,omitempty"` +} + +// MeterAggregation The aggregation type to use for the meter. +type MeterAggregation string + +// MeterPaginatedResponse Cursor paginated response. +type MeterPaginatedResponse struct { + Data []Meter `json:"data"` + + // Meta Pagination metadata. + Meta CursorMeta `json:"meta"` +} + // MeteringEvent Metering event following the CloudEvents specification. type MeteringEvent struct { // Data The event payload. @@ -454,6 +574,70 @@ type UnauthorizedError struct { Type interface{} `json:"type,omitempty"` } +// UpdateCustomerRequest Customer update request. +type UpdateCustomerRequest struct { + // BillingAddress The billing address of the customer. + // Used for tax and invoicing. + BillingAddress *BillingAddress `json:"billing_address,omitempty"` + + // Currency Currency of the customer. + // Used for billing, tax and invoicing. + Currency *CurrencyCode `json:"currency,omitempty"` + + // Description Optional description of the resource. + // + // Maximum 1024 characters. + Description *string `json:"description,omitempty"` + + // Labels Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + // + // Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + Labels *Labels `json:"labels,omitempty"` + + // Name Display name of the resource. + // + // Between 1 and 256 characters. + Name *string `json:"name,omitempty"` + + // PrimaryEmail The primary email address of the customer. + PrimaryEmail *string `json:"primary_email,omitempty"` + + // UsageAttribution Mapping to attribute metered usage to the customer by the event subject. + UsageAttribution *BillingCustomerUsageAttribution `json:"usage_attribution,omitempty"` +} + +// UpsertCustomerRequest Customer upsert request. +type UpsertCustomerRequest struct { + // BillingAddress The billing address of the customer. + // Used for tax and invoicing. + BillingAddress *BillingAddress `json:"billing_address,omitempty"` + + // Currency Currency of the customer. + // Used for billing, tax and invoicing. + Currency *CurrencyCode `json:"currency,omitempty"` + + // Description Optional description of the resource. + // + // Maximum 1024 characters. + Description *string `json:"description,omitempty"` + + // Labels Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + // + // Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + Labels *Labels `json:"labels,omitempty"` + + // Name Display name of the resource. + // + // Between 1 and 256 characters. + Name string `json:"name"` + + // PrimaryEmail The primary email address of the customer. + PrimaryEmail *string `json:"primary_email,omitempty"` + + // UsageAttribution Mapping to attribute metered usage to the customer by the event subject. + UsageAttribution *BillingCustomerUsageAttribution `json:"usage_attribution,omitempty"` +} + // CursorPageQuery defines model for CursorPageQuery. type CursorPageQuery = CursorPageParameters @@ -492,9 +676,21 @@ type IngestMeteringEventsJSONBody struct { // IngestMeteringEventsJSONBody1 defines parameters for IngestMeteringEvents. type IngestMeteringEventsJSONBody1 = []MeteringEvent +// ListMetersParams defines parameters for ListMeters. +type ListMetersParams struct { + // Page Determines which page of the collection to retrieve. + Page *CursorPageQuery `form:"page,omitempty" json:"page,omitempty"` +} + // CreateCustomerJSONRequestBody defines body for CreateCustomer for application/json ContentType. type CreateCustomerJSONRequestBody = CreateCustomerRequest +// UpdateCustomerJSONRequestBody defines body for UpdateCustomer for application/json ContentType. +type UpdateCustomerJSONRequestBody = UpdateCustomerRequest + +// UpsertCustomerJSONRequestBody defines body for UpsertCustomer for application/json ContentType. +type UpsertCustomerJSONRequestBody = UpsertCustomerRequest + // IngestMeteringEventsApplicationCloudeventsPlusJSONRequestBody defines body for IngestMeteringEvents for application/cloudevents+json ContentType. type IngestMeteringEventsApplicationCloudeventsPlusJSONRequestBody = MeteringEvent @@ -504,6 +700,9 @@ type IngestMeteringEventsApplicationCloudeventsBatchPlusJSONRequestBody = Ingest // IngestMeteringEventsJSONRequestBody defines body for IngestMeteringEvents for application/json ContentType. type IngestMeteringEventsJSONRequestBody IngestMeteringEventsJSONBody +// CreateMeterJSONRequestBody defines body for CreateMeter for application/json ContentType. +type CreateMeterJSONRequestBody = CreateMeterRequest + // AsInvalidParameterStandard returns the union data inside the InvalidParameters_Item as a InvalidParameterStandard func (t InvalidParameters_Item) AsInvalidParameterStandard() (InvalidParameterStandard, error) { var body InvalidParameterStandard @@ -652,12 +851,30 @@ type ServerInterface interface { // Create customer // (POST /openmeter/customers) CreateCustomer(w http.ResponseWriter, r *http.Request) + // Delete customer + // (DELETE /openmeter/customers/{customerId}) + DeleteCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) // Get customer // (GET /openmeter/customers/{customerId}) GetCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) + // Update customer + // (PATCH /openmeter/customers/{customerId}) + UpdateCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) + // Upsert customer + // (PUT /openmeter/customers/{customerId}) + UpsertCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) // Ingest metering events // (POST /openmeter/events) IngestMeteringEvents(w http.ResponseWriter, r *http.Request) + // List meters + // (GET /openmeter/meters) + ListMeters(w http.ResponseWriter, r *http.Request, params ListMetersParams) + // Create meter + // (POST /openmeter/meters) + CreateMeter(w http.ResponseWriter, r *http.Request) + // Get meter + // (GET /openmeter/meters/{meterId}) + GetMeter(w http.ResponseWriter, r *http.Request, meterId ULID) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -676,18 +893,54 @@ func (_ Unimplemented) CreateCustomer(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Delete customer +// (DELETE /openmeter/customers/{customerId}) +func (_ Unimplemented) DeleteCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) { + w.WriteHeader(http.StatusNotImplemented) +} + // Get customer // (GET /openmeter/customers/{customerId}) func (_ Unimplemented) GetCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) { w.WriteHeader(http.StatusNotImplemented) } +// Update customer +// (PATCH /openmeter/customers/{customerId}) +func (_ Unimplemented) UpdateCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Upsert customer +// (PUT /openmeter/customers/{customerId}) +func (_ Unimplemented) UpsertCustomer(w http.ResponseWriter, r *http.Request, customerId ULID) { + w.WriteHeader(http.StatusNotImplemented) +} + // Ingest metering events // (POST /openmeter/events) func (_ Unimplemented) IngestMeteringEvents(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// List meters +// (GET /openmeter/meters) +func (_ Unimplemented) ListMeters(w http.ResponseWriter, r *http.Request, params ListMetersParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Create meter +// (POST /openmeter/meters) +func (_ Unimplemented) CreateMeter(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Get meter +// (GET /openmeter/meters/{meterId}) +func (_ Unimplemented) GetMeter(w http.ResponseWriter, r *http.Request, meterId ULID) { + w.WriteHeader(http.StatusNotImplemented) +} + // ServerInterfaceWrapper converts contexts to parameters. type ServerInterfaceWrapper struct { Handler ServerInterface @@ -738,6 +991,31 @@ func (siw *ServerInterfaceWrapper) CreateCustomer(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } +// DeleteCustomer operation middleware +func (siw *ServerInterfaceWrapper) DeleteCustomer(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "customerId" ------------- + var customerId ULID + + err = runtime.BindStyledParameterWithOptions("simple", "customerId", chi.URLParam(r, "customerId"), &customerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "customerId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeleteCustomer(w, r, customerId) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + // GetCustomer operation middleware func (siw *ServerInterfaceWrapper) GetCustomer(w http.ResponseWriter, r *http.Request) { @@ -763,6 +1041,56 @@ func (siw *ServerInterfaceWrapper) GetCustomer(w http.ResponseWriter, r *http.Re handler.ServeHTTP(w, r) } +// UpdateCustomer operation middleware +func (siw *ServerInterfaceWrapper) UpdateCustomer(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "customerId" ------------- + var customerId ULID + + err = runtime.BindStyledParameterWithOptions("simple", "customerId", chi.URLParam(r, "customerId"), &customerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "customerId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateCustomer(w, r, customerId) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + +// UpsertCustomer operation middleware +func (siw *ServerInterfaceWrapper) UpsertCustomer(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "customerId" ------------- + var customerId ULID + + err = runtime.BindStyledParameterWithOptions("simple", "customerId", chi.URLParam(r, "customerId"), &customerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "customerId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpsertCustomer(w, r, customerId) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + // IngestMeteringEvents operation middleware func (siw *ServerInterfaceWrapper) IngestMeteringEvents(w http.ResponseWriter, r *http.Request) { @@ -777,6 +1105,72 @@ func (siw *ServerInterfaceWrapper) IngestMeteringEvents(w http.ResponseWriter, r handler.ServeHTTP(w, r) } +// ListMeters operation middleware +func (siw *ServerInterfaceWrapper) ListMeters(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ListMetersParams + + // ------------- Optional query parameter "page" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page", r.URL.Query(), ¶ms.Page) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListMeters(w, r, params) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateMeter operation middleware +func (siw *ServerInterfaceWrapper) CreateMeter(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateMeter(w, r) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + +// GetMeter operation middleware +func (siw *ServerInterfaceWrapper) GetMeter(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "meterId" ------------- + var meterId ULID + + err = runtime.BindStyledParameterWithOptions("simple", "meterId", chi.URLParam(r, "meterId"), &meterId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "meterId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetMeter(w, r, meterId) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + type UnescapedCookieParamError struct { ParamName string Err error @@ -896,12 +1290,30 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/openmeter/customers", wrapper.CreateCustomer) }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/openmeter/customers/{customerId}", wrapper.DeleteCustomer) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/openmeter/customers/{customerId}", wrapper.GetCustomer) }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/openmeter/customers/{customerId}", wrapper.UpdateCustomer) + }) + r.Group(func(r chi.Router) { + r.Put(options.BaseURL+"/openmeter/customers/{customerId}", wrapper.UpsertCustomer) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/openmeter/events", wrapper.IngestMeteringEvents) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/openmeter/meters", wrapper.ListMeters) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/openmeter/meters", wrapper.CreateMeter) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/openmeter/meters/{meterId}", wrapper.GetMeter) + }) return r } @@ -909,86 +1321,102 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "Wl+v/y8AAP//fm0GumtRAAA=", + "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", + "srGDfQAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/handlers/convert.gen.go b/api/v3/handlers/convert.gen.go index 41bd885d9a..332576ce47 100644 --- a/api/v3/handlers/convert.gen.go +++ b/api/v3/handlers/convert.gen.go @@ -4,10 +4,12 @@ package handlers import ( + "fmt" nullable "github.com/oapi-codegen/nullable" v3 "github.com/openmeterio/openmeter/api/v3" response "github.com/openmeterio/openmeter/api/v3/response" customer "github.com/openmeterio/openmeter/openmeter/customer" + meter "github.com/openmeterio/openmeter/openmeter/meter" currencyx "github.com/openmeterio/openmeter/pkg/currencyx" models "github.com/openmeterio/openmeter/pkg/models" "time" @@ -68,12 +70,74 @@ func init() { v3CustomerPaginatedResponse.Meta = responseCursorMetaToV3CursorMeta(source.Meta) return v3CustomerPaginatedResponse } + ConvertMeter = func(source meter.Meter) (v3.Meter, error) { + var v3Meter v3.Meter + v3MeterAggregation, err := ConvertMeterAggregation(source.Aggregation) + if err != nil { + return v3Meter, err + } + v3Meter.Aggregation = v3MeterAggregation + v3Meter.CreatedAt = timeTimeToPTimeTime(source.ManagedResource.ManagedModel.CreatedAt) + v3Meter.DeletedAt = source.ManagedResource.ManagedModel.DeletedAt + v3Meter.Description = source.ManagedResource.Description + v3Meter.Dimensions = &source.GroupBy + v3Meter.EventFrom = source.EventFrom + v3Meter.EventTypeFilter = source.EventType + v3Meter.Id = source.ManagedResource.ID + v3Meter.Key = source.Key + v3Meter.Labels = modelsMetadataToPV3Labels(source.Metadata) + v3Meter.Name = source.ManagedResource.Name + v3Meter.UpdatedAt = timeTimeToPTimeTime(source.ManagedResource.ManagedModel.UpdatedAt) + v3Meter.ValueProperty = source.ValueProperty + return v3Meter, nil + } + ConvertMeterAggregation = func(source meter.MeterAggregation) (v3.MeterAggregation, error) { + var v3MeterAggregation v3.MeterAggregation + switch source { + case meter.MeterAggregationAvg: + v3MeterAggregation = v3.MeterAggregationAvg + case meter.MeterAggregationCount: + v3MeterAggregation = v3.MeterAggregationCount + case meter.MeterAggregationLatest: + v3MeterAggregation = v3.MeterAggregationLatest + case meter.MeterAggregationMax: + v3MeterAggregation = v3.MeterAggregationMax + case meter.MeterAggregationMin: + v3MeterAggregation = v3.MeterAggregationMin + case meter.MeterAggregationSum: + v3MeterAggregation = v3.MeterAggregationSum + case meter.MeterAggregationUniqueCount: + v3MeterAggregation = v3.MeterAggregationUniqueCount + default: + return v3MeterAggregation, fmt.Errorf("unexpected enum element: %v", source) + } + return v3MeterAggregation, nil + } + ConvertMetersListResponse = func(source response.CursorPaginationResponse[meter.Meter]) (v3.MeterPaginatedResponse, error) { + var v3MeterPaginatedResponse v3.MeterPaginatedResponse + if source.Data != nil { + v3MeterPaginatedResponse.Data = make([]v3.Meter, len(source.Data)) + for i := 0; i < len(source.Data); i++ { + v3Meter, err := ConvertMeter(source.Data[i]) + if err != nil { + return v3MeterPaginatedResponse, err + } + v3MeterPaginatedResponse.Data[i] = v3Meter + } + } + v3MeterPaginatedResponse.Meta = responseCursorMetaToV3CursorMeta(source.Meta) + return v3MeterPaginatedResponse, nil + } } func customerCustomerUsageAttributionToPV3BillingCustomerUsageAttribution(source customer.CustomerUsageAttribution) *v3.BillingCustomerUsageAttribution { var v3BillingCustomerUsageAttribution v3.BillingCustomerUsageAttribution v3BillingCustomerUsageAttribution.SubjectKeys = source.SubjectKeys return &v3BillingCustomerUsageAttribution } +func modelsMetadataToPV3Labels(source models.Metadata) *v3.Labels { + v3Labels := modelsMetadataToV3Labels(source) + return &v3Labels +} func modelsMetadataToV3Labels(source models.Metadata) v3.Labels { var v3Labels v3.Labels if source != nil { diff --git a/api/v3/handlers/convert.go b/api/v3/handlers/convert.go index 08726928f3..fd8c2b7df5 100644 --- a/api/v3/handlers/convert.go +++ b/api/v3/handlers/convert.go @@ -8,6 +8,7 @@ import ( api "github.com/openmeterio/openmeter/api/v3" "github.com/openmeterio/openmeter/api/v3/response" "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/meter" "github.com/openmeterio/openmeter/pkg/pagination/v2" "github.com/samber/lo" ) @@ -35,6 +36,19 @@ var ( // goverter:ignore Annotation ConvertCreateCustomerToCustomerMutate func(createCustomerRequest api.CreateCustomerRequest) customer.CustomerMutate ConvertCustomerListResponse func(customers response.CursorPaginationResponse[customer.Customer]) api.CustomerPaginatedResponse + // goverter:map Metadata Labels + // goverter:map GroupBy Dimensions + // goverter:map EventType EventTypeFilter + // goverter:map ManagedResource.ID Id + // goverter:map ManagedResource.Description Description + // goverter:map ManagedResource.Name Name + // goverter:map ManagedResource.ManagedModel.CreatedAt CreatedAt + // goverter:map ManagedResource.ManagedModel.UpdatedAt UpdatedAt + // goverter:map ManagedResource.ManagedModel.DeletedAt DeletedAt + ConvertMeter func(meter.Meter) (api.Meter, error) + // goverter:enum:unknown @error + ConvertMeterAggregation func(aggregation meter.MeterAggregation) (api.MeterAggregation, error) + ConvertMetersListResponse func(meters response.CursorPaginationResponse[meter.Meter]) (api.MeterPaginatedResponse, error) ) //goverter:context namespace @@ -49,3 +63,11 @@ type Customer struct { func (c Customer) Cursor() pagination.Cursor { return pagination.NewCursor(lo.FromPtrOr(c.CreatedAt, time.Now()), c.Id) } + +type Meter struct { + api.Meter +} + +func (m Meter) Cursor() pagination.Cursor { + return pagination.NewCursor(lo.FromPtrOr(m.CreatedAt, time.Now()), m.Id) +} diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index 7f5dbe7058..b58fa92ff1 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -77,7 +77,10 @@ func (h *customerHandler) ListCustomers() ListCustomersHandler { }) // Map the customers to the API - return response.NewCursorPaginationResponse(customers), nil + r := response.NewCursorPaginationResponse(customers) + // TODO: set the size of the page from the request params + // r.Meta.Page.Size = request.Page.Size + return r, nil }, commonhttp.JSONResponseEncoderWithStatus[ListCustomersResponse](http.StatusOK), httptransport.AppendOptions( diff --git a/api/v3/handlers/meters.go b/api/v3/handlers/meters.go new file mode 100644 index 0000000000..a59d14ab38 --- /dev/null +++ b/api/v3/handlers/meters.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + response "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +type MeterHandler interface { + ListMeters() ListMetersHandler + // GetMeter() GetMeterHandler + // CreateMeter() CreateMeterHandler +} + +type meterHandler struct { + service meter.Service + resolveNamespace func(ctx context.Context) (string, error) + options []httptransport.HandlerOption +} + +func NewMeterHandler( + resolveNamespace func(ctx context.Context) (string, error), + service meter.Service, + options ...httptransport.HandlerOption, +) MeterHandler { + return &meterHandler{ + service: service, + resolveNamespace: resolveNamespace, + options: options, + } +} + +type ( + ListMetersParams = api.ListMetersParams + ListMetersRequest = meter.ListMetersParams + ListMetersResponse = response.CursorPaginationResponse[Meter] + ListMetersHandler httptransport.HandlerWithArgs[ListMetersRequest, ListMetersResponse, ListMetersParams] +) + +// ListMeters returns a handler for listing meters. +func (h *meterHandler) ListMeters() ListMetersHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListMetersParams) (ListMetersRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListMetersRequest{}, err + } + + return ListMetersRequest{ + Namespace: ns, + + // TODO: pagination + }, nil + }, + func(ctx context.Context, request ListMetersRequest) (ListMetersResponse, error) { + result, err := h.service.ListMeters(ctx, request) + if err != nil { + return ListMetersResponse{}, err + } + + meters, err := slicesx.MapWithErr(result.Items, func(item meter.Meter) (Meter, error) { + m, err := ConvertMeter(item) + return Meter{ + Meter: m, + }, err + }) + if err != nil { + return ListMetersResponse{}, apierrors.NewInternalError(ctx, err) + } + + // Response + resp := response.NewCursorPaginationResponse(meters) + + return resp, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListMetersResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("listMeters"), + )..., + ) +} diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index 70bd32bdc0..c08a435315 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -17,6 +17,8 @@ servers: description: Local variables: {} tags: + - name: Meters + description: Meters specify how to aggregate events for billing and analytics purposes. Meters can be configured with multiple aggregation methods and groupings. Multiple meters can be created for the same event type, enabling flexible metering scenarios. - name: Billing Customers description: Customers are used to track usage of your product or service. Customers can be individuals or organizations that can subscribe to plans and have access to features. - name: Metering Events @@ -112,6 +114,110 @@ paths: - Billing Customers x-internal: true x-unstable: true + put: + operationId: upsert-customer + summary: Upsert customer + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Customer upsert response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomer' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/NotAvailable' + tags: + - Billing Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertCustomerRequest' + x-internal: true + x-unstable: true + patch: + operationId: update-customer + summary: Update customer + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Customer updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomer' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/NotAvailable' + tags: + - Billing Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCustomerRequest' + x-internal: true + x-unstable: true + delete: + operationId: delete-customer + summary: Delete customer + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/NotAvailable' + tags: + - Billing Customers + x-internal: true + x-unstable: true /openmeter/events: post: operationId: ingest-metering-events @@ -152,6 +258,90 @@ paths: - type: array items: $ref: '#/components/schemas/MeteringEvent' + /openmeter/meters: + post: + operationId: create-meter + summary: Create meter + responses: + '201': + description: Meter created response. + content: + application/json: + schema: + $ref: '#/components/schemas/Meter' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/NotAvailable' + tags: + - Meters + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMeterRequest' + get: + operationId: list-meters + summary: List meters + parameters: + - $ref: '#/components/parameters/CursorPageQuery' + responses: + '200': + description: Cursor paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/MeterPaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/NotAvailable' + tags: + - Meters + /openmeter/meters/{meterId}: + get: + operationId: get-meter + summary: Get meter + parameters: + - name: meterId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Meter response. + content: + application/json: + schema: + $ref: '#/components/schemas/Meter' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/NotAvailable' + tags: + - Meters components: schemas: BillingAddress: @@ -332,6 +522,70 @@ components: Used for tax and invoicing. title: Billing Address description: Customer create request. + CreateMeterRequest: + type: object + required: + - name + - key + - aggregation + - event_type_filter + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + $ref: '#/components/schemas/ResourceKey' + aggregation: + allOf: + - $ref: '#/components/schemas/MeterAggregation' + description: The aggregation type to use for the meter. + event_type_filter: + type: string + minLength: 1 + description: The event type to include in the aggregation. + example: prompt + event_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The date since the meter should include events. + Useful to skip old events. + If not specified, all historical events are included. + value_property: + type: string + minLength: 1 + description: |- + JSONPath expression to extract the value from the ingested event's data property. + + The ingested value for sum, avg, min, and max aggregations is a number or a string that can be parsed to a number. + + For unique_count aggregation, the ingested value must be a string. For count aggregation the value_property is ignored. + example: $.tokens + dimensions: + type: object + additionalProperties: + type: string + description: |- + Named JSONPath expressions to extract the group by values from the event data. + + Keys must be unique and consist only alphanumeric and underscore characters. + example: + type: $.type + description: Meter create request. CurrencyCode: type: string minLength: 3 @@ -363,6 +617,127 @@ components: description: '[RFC3339](https://tools.ietf.org/html/rfc3339) formatted date-time string in UTC.' title: RFC3339 Date-Time example: '2023-01-01T01:01:01.001Z' + Meter: + type: object + required: + - id + - name + - key + - aggregation + - event_type_filter + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + key: + $ref: '#/components/schemas/ResourceKey' + aggregation: + allOf: + - $ref: '#/components/schemas/MeterAggregation' + description: The aggregation type to use for the meter. + event_type_filter: + type: string + minLength: 1 + description: The event type to include in the aggregation. + example: prompt + event_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The date since the meter should include events. + Useful to skip old events. + If not specified, all historical events are included. + value_property: + type: string + minLength: 1 + description: |- + JSONPath expression to extract the value from the ingested event's data property. + + The ingested value for sum, avg, min, and max aggregations is a number or a string that can be parsed to a number. + + For unique_count aggregation, the ingested value must be a string. For count aggregation the value_property is ignored. + example: $.tokens + dimensions: + type: object + additionalProperties: + type: string + description: |- + Named JSONPath expressions to extract the group by values from the event data. + + Keys must be unique and consist only alphanumeric and underscore characters. + example: + type: $.type + description: A meter is a configuration that defines how to match and aggregate events. + example: + id: 01G65Z755AFWAKHE12NY0CQ9FH + key: tokens_total + name: Tokens Total + description: AI Token Usage + aggregation: sum + event_type_filter: prompt + value_property: $.tokens + dimensions: + model: $.model + type: $.type + created_at: '2024-01-01T01:01:01.001Z' + updated_at: '2024-01-01T01:01:01.001Z' + MeterAggregation: + type: string + enum: + - sum + - count + - unique_count + - avg + - min + - max + - latest + description: The aggregation type to use for the meter. + MeterPaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Meter' + meta: + $ref: '#/components/schemas/CursorMeta' + description: Cursor paginated response. MeteringEvent: type: object required: @@ -454,6 +829,96 @@ components: description: ULID (Universally Unique Lexicographically Sortable Identifier). title: ULID example: 01G65Z755AFWAKHE12NY0CQ9FH + UpdateCustomerRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + usage_attribution: + allOf: + - $ref: '#/components/schemas/BillingCustomerUsageAttribution' + description: Mapping to attribute metered usage to the customer by the event subject. + title: Usage Attribution + primary_email: + type: string + description: The primary email address of the customer. + title: Primary Email + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: |- + Currency of the customer. + Used for billing, tax and invoicing. + title: Currency + billing_address: + allOf: + - $ref: '#/components/schemas/BillingAddress' + description: |- + The billing address of the customer. + Used for tax and invoicing. + title: Billing Address + description: Customer update request. + UpsertCustomerRequest: + type: object + required: + - name + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + usage_attribution: + allOf: + - $ref: '#/components/schemas/BillingCustomerUsageAttribution' + description: Mapping to attribute metered usage to the customer by the event subject. + title: Usage Attribution + primary_email: + type: string + description: The primary email address of the customer. + title: Primary Email + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: |- + Currency of the customer. + Used for billing, tax and invoicing. + title: Currency + billing_address: + allOf: + - $ref: '#/components/schemas/BillingAddress' + description: |- + The billing address of the customer. + Used for tax and invoicing. + title: Billing Address + description: Customer upsert request. CursorPageParameters: type: object properties: diff --git a/api/v3/response/pagination.go b/api/v3/response/pagination.go index 233b7beba7..bc42f32a4b 100644 --- a/api/v3/response/pagination.go +++ b/api/v3/response/pagination.go @@ -44,7 +44,6 @@ func NewCursorPaginationResponse[T pagination.Item](items []T) CursorPaginationR Data: items, Meta: CursorMeta{ Page: CursorMetaPage{ - Size: float32(len(items)), Next: nullable.NewNullNullable[string](), Previous: nullable.NewNullNullable[string](), }, diff --git a/api/v3/server/customers.go b/api/v3/server/customers.go index 68f46b7e50..6da87931f9 100644 --- a/api/v3/server/customers.go +++ b/api/v3/server/customers.go @@ -1,9 +1,11 @@ package server import ( + "errors" "net/http" api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" ) func (s *Server) CreateCustomer(w http.ResponseWriter, r *http.Request) { @@ -17,3 +19,15 @@ func (s *Server) GetCustomer(w http.ResponseWriter, r *http.Request, customerId func (s *Server) ListCustomers(w http.ResponseWriter, r *http.Request, params api.ListCustomersParams) { s.customerHandler.ListCustomers().With(params).ServeHTTP(w, r) } + +func (s *Server) UpsertCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + apierrors.NewNotImplementedError(r.Context(), errors.New("not implemented")).HandleAPIError(w, r) +} + +func (s *Server) UpdateCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + apierrors.NewNotImplementedError(r.Context(), errors.New("not implemented")).HandleAPIError(w, r) +} + +func (s *Server) DeleteCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + apierrors.NewNotImplementedError(r.Context(), errors.New("not implemented")).HandleAPIError(w, r) +} diff --git a/api/v3/server/meters.go b/api/v3/server/meters.go new file mode 100644 index 0000000000..4b1674f506 --- /dev/null +++ b/api/v3/server/meters.go @@ -0,0 +1,21 @@ +package server + +import ( + "errors" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +func (s *Server) ListMeters(w http.ResponseWriter, r *http.Request, params api.ListMetersParams) { + s.meterHandler.ListMeters().With(params).ServeHTTP(w, r) +} + +func (s *Server) GetMeter(w http.ResponseWriter, r *http.Request, meterId api.ULID) { + apierrors.NewNotImplementedError(r.Context(), errors.New("not implemented")).HandleAPIError(w, r) +} + +func (s *Server) CreateMeter(w http.ResponseWriter, r *http.Request) { + apierrors.NewNotImplementedError(r.Context(), errors.New("not implemented")).HandleAPIError(w, r) +} diff --git a/api/v3/server/server.go b/api/v3/server/server.go index f961a6852a..ce6ede50dc 100644 --- a/api/v3/server/server.go +++ b/api/v3/server/server.go @@ -15,6 +15,7 @@ import ( "github.com/openmeterio/openmeter/api/v3/handlers" "github.com/openmeterio/openmeter/api/v3/render" "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/meter" "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" ) @@ -24,6 +25,7 @@ type Config struct { // services CustomerService customer.Service + MeterService meter.Service } type Server struct { @@ -31,6 +33,7 @@ type Server struct { swagger *openapi3.T customerHandler handlers.CustomerHandler + meterHandler handlers.MeterHandler middlewares []api.MiddlewareFunc } @@ -77,12 +80,14 @@ func NewServer(config *Config) (*Server, error) { } customerHandler := handlers.NewCustomerHandler(resolveNamespace, config.CustomerService) + meterHandler := handlers.NewMeterHandler(resolveNamespace, config.MeterService) return &Server{ Config: config, swagger: swagger, middlewares: middlewares, customerHandler: customerHandler, + meterHandler: meterHandler, }, nil } diff --git a/openmeter/server/server.go b/openmeter/server/server.go index 563fa003c7..9b0b3f7f64 100644 --- a/openmeter/server/server.go +++ b/openmeter/server/server.go @@ -99,6 +99,7 @@ func NewServer(config *Config) (*Server, error) { v3API, err := v3server.NewServer(&v3server.Config{ BaseURL: "/api/v3", CustomerService: config.RouterConfig.Customer, + MeterService: config.RouterConfig.MeterManageService, NamespaceDecoder: staticNamespaceDecoder, }) if err != nil { From 5271347765d30e9072f05b62ef161a964e174d03 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Sat, 6 Dec 2025 00:44:28 +0100 Subject: [PATCH 03/18] feat(filter): add support for ent predicates --- pkg/filter/filter.go | 148 +++++++ pkg/filter/filter_test.go | 847 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 995 insertions(+) diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go index 8c1f2d6908..d017bb836c 100644 --- a/pkg/filter/filter.go +++ b/pkg/filter/filter.go @@ -4,6 +4,7 @@ import ( "errors" "time" + "entgo.io/ent/dialect/sql" "github.com/huandu/go-sqlbuilder" "github.com/samber/lo" ) @@ -16,6 +17,8 @@ type Filter interface { ValidateWithComplexity(maxDepth int) error // SelectWhereExpr converts the filter to a SQL WHERE expression. SelectWhereExpr(field string, q *sqlbuilder.SelectBuilder) string + // SelectWherePredicate converts the filter to a predicate for ent.Query.Where. + SelectWherePredicate(field string) *sql.Predicate // IsEmpty returns true if the filter is empty. IsEmpty() bool } @@ -157,6 +160,65 @@ func (f FilterString) SelectWhereExpr(field string, q *sqlbuilder.SelectBuilder) } } +// SelectWherePredicate converts the filter to an ent *sql.Predicate. +func (f FilterString) SelectWherePredicate(field string) *sql.Predicate { + switch { + case f.Eq != nil: + return sql.EQ(field, *f.Eq) + case f.Ne != nil: + return sql.NEQ(field, *f.Ne) + case f.In != nil: + // We must cast *[]string to []interface{} for sql.In + vals := lo.ToAnySlice(*f.In) + return sql.In(field, vals...) + case f.Nin != nil: + vals := lo.ToAnySlice(*f.Nin) + return sql.NotIn(field, vals...) + case f.Like != nil: + return sql.Like(field, *f.Like) + case f.Nlike != nil: + return sql.Not(sql.Like(field, *f.Nlike)) + case f.Ilike != nil: + // ILike is technically PostgreSQL specific, but ent/sql handles it + // TODO: use ILIKE somehow + return sql.EqualFold(field, *f.Ilike) // or sql.Expr("? ILIKE ?", ...) + case f.Nilike != nil: + // TODO: use ILIKE somehow + return sql.Not(sql.EqualFold(field, *f.Nilike)) + case f.Gt != nil: + return sql.GT(field, *f.Gt) + case f.Gte != nil: + return sql.GTE(field, *f.Gte) + case f.Lt != nil: + return sql.LT(field, *f.Lt) + case f.Lte != nil: + return sql.LTE(field, *f.Lte) + case f.And != nil: + // Recursively map the children + preds := lo.Map(*f.And, func(sub FilterString, _ int) *sql.Predicate { + return sub.SelectWherePredicate(field) + }) + return sql.And(preds...) + case f.Or != nil: + preds := lo.Map(*f.Or, func(sub FilterString, _ int) *sql.Predicate { + return sub.SelectWherePredicate(field) + }) + return sql.Or(preds...) + default: + // No filter applied, return "always true" or nil + return nil + } +} + +func (f FilterString) Where(colName string) func(*sql.Selector) { + return func(s *sql.Selector) { + p := f.SelectWherePredicate(s.C(colName)) + if p != nil { + s.Where(p) + } + } +} + // FilterInteger is a filter for an integer field. type FilterInteger struct { Eq *int `json:"$eq,omitempty"` @@ -266,6 +328,33 @@ func (f FilterInteger) SelectWhereExpr(field string, q *sqlbuilder.SelectBuilder } } +func (f FilterInteger) SelectWherePredicate(field string) *sql.Predicate { + switch { + case f.Eq != nil: + return sql.EQ(field, *f.Eq) + case f.Ne != nil: + return sql.NEQ(field, *f.Ne) + case f.Gt != nil: + return sql.GT(field, *f.Gt) + case f.Gte != nil: + return sql.GTE(field, *f.Gte) + case f.Lt != nil: + return sql.LT(field, *f.Lt) + case f.Lte != nil: + return sql.LTE(field, *f.Lte) + case f.And != nil: + return sql.And(lo.Map(*f.And, func(filter FilterInteger, _ int) *sql.Predicate { + return filter.SelectWherePredicate(field) + })...) + case f.Or != nil: + return sql.Or(lo.Map(*f.Or, func(filter FilterInteger, _ int) *sql.Predicate { + return filter.SelectWherePredicate(field) + })...) + default: + return nil + } +} + // FilterFloat is a filter for a float field. type FilterFloat struct { Eq *float64 `json:"$eq,omitempty"` @@ -374,6 +463,33 @@ func (f FilterFloat) SelectWhereExpr(field string, q *sqlbuilder.SelectBuilder) } } +func (f FilterFloat) SelectWherePredicate(field string) *sql.Predicate { + switch { + case f.Eq != nil: + return sql.EQ(field, *f.Eq) + case f.Ne != nil: + return sql.NEQ(field, *f.Ne) + case f.Gt != nil: + return sql.GT(field, *f.Gt) + case f.Gte != nil: + return sql.GTE(field, *f.Gte) + case f.Lt != nil: + return sql.LT(field, *f.Lt) + case f.Lte != nil: + return sql.LTE(field, *f.Lte) + case f.And != nil: + return sql.And(lo.Map(*f.And, func(filter FilterFloat, _ int) *sql.Predicate { + return filter.SelectWherePredicate(field) + })...) + case f.Or != nil: + return sql.Or(lo.Map(*f.Or, func(filter FilterFloat, _ int) *sql.Predicate { + return filter.SelectWherePredicate(field) + })...) + default: + return nil + } +} + // FilterTime is a filter for a time field. type FilterTime struct { Gt *time.Time `json:"$gt,omitempty"` @@ -477,6 +593,29 @@ func (f FilterTime) SelectWhereExpr(field string, q *sqlbuilder.SelectBuilder) s } } +func (f FilterTime) SelectWherePredicate(field string) *sql.Predicate { + switch { + case f.Gt != nil: + return sql.GT(field, *f.Gt) + case f.Gte != nil: + return sql.GTE(field, *f.Gte) + case f.Lt != nil: + return sql.LT(field, *f.Lt) + case f.Lte != nil: + return sql.LTE(field, *f.Lte) + case f.And != nil: + return sql.And(lo.Map(*f.And, func(filter FilterTime, _ int) *sql.Predicate { + return filter.SelectWherePredicate(field) + })...) + case f.Or != nil: + return sql.Or(lo.Map(*f.Or, func(filter FilterTime, _ int) *sql.Predicate { + return filter.SelectWherePredicate(field) + })...) + default: + return nil + } +} + // FilterBoolean is a filter for a boolean field. type FilterBoolean struct { Eq *bool `json:"$eq,omitempty"` @@ -508,6 +647,15 @@ func (f FilterBoolean) SelectWhereExpr(field string, q *sqlbuilder.SelectBuilder } } +func (f FilterBoolean) SelectWherePredicate(field string) *sql.Predicate { + switch { + case f.Eq != nil: + return sql.EQ(field, *f.Eq) + default: + return nil + } +} + // validateMutuallyExclusiveFilters checks if more than one filter field is set, as filters are mutually exclusive func validateMutuallyExclusiveFilters(fields []bool) error { nonNilFilters := lo.CountBy(fields, func(b bool) bool { return b }) diff --git a/pkg/filter/filter_test.go b/pkg/filter/filter_test.go index 59c2e581e3..0916dec95a 100644 --- a/pkg/filter/filter_test.go +++ b/pkg/filter/filter_test.go @@ -1,9 +1,12 @@ package filter_test import ( + "strings" "testing" "time" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" "github.com/huandu/go-sqlbuilder" "github.com/samber/lo" "github.com/stretchr/testify/assert" @@ -1896,3 +1899,847 @@ func TestFilterTime_IsEmpty(t *testing.T) { }) } } + +// buildSQLFromPredicate builds a SQL query from a predicate and returns only the WHERE clause and arguments +func buildSQLFromPredicate(pred *sql.Predicate) (string, []interface{}) { + if pred == nil { + return "", nil + } + builder := sql.Dialect(dialect.Postgres) + selector := builder.Select("*").From(sql.Table("test_table")) + selector.Where(pred) + query, args := selector.Query() + + // Extract only the WHERE clause (everything after "WHERE ") + wherePrefix := "WHERE " + whereIdx := strings.Index(query, wherePrefix) + if whereIdx == -1 { + return "", args + } + return query[whereIdx+len(wherePrefix):], args +} + +func TestFilterString_SelectWherePredicate(t *testing.T) { + tests := []struct { + name string + filter filter.FilterString + field string + wantNil bool + wantSQL string + wantArgs []interface{} + }{ + { + name: "empty filter", + filter: filter.FilterString{}, + field: "test_field", + wantNil: true, + wantSQL: "", + wantArgs: nil, + }, + { + name: "eq filter", + filter: filter.FilterString{ + Eq: lo.ToPtr("test"), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1", + wantArgs: []interface{}{"test"}, + }, + { + name: "ne filter", + filter: filter.FilterString{ + Ne: lo.ToPtr("test"), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" <> $1", + wantArgs: []interface{}{"test"}, + }, + { + name: "in filter", + filter: filter.FilterString{ + In: &[]string{"test1", "test2"}, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" IN ($1, $2)", + wantArgs: []interface{}{"test1", "test2"}, + }, + { + name: "nin filter", + filter: filter.FilterString{ + Nin: &[]string{"test1", "test2"}, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" NOT IN ($1, $2)", + wantArgs: []interface{}{"test1", "test2"}, + }, + { + name: "like filter", + filter: filter.FilterString{ + Like: lo.ToPtr("%test%"), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" LIKE $1", + wantArgs: []interface{}{"%test%"}, + }, + { + name: "nlike filter", + filter: filter.FilterString{ + Nlike: lo.ToPtr("%test%"), + }, + field: "test_field", + wantNil: false, + wantSQL: "NOT (\"test_field\" LIKE $1)", + wantArgs: []interface{}{"%test%"}, + }, + { + name: "ilike filter", + filter: filter.FilterString{ + Ilike: lo.ToPtr("%test%"), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" ILIKE $1", + wantArgs: []interface{}{"%test%"}, + }, + { + name: "nilike filter", + filter: filter.FilterString{ + Nilike: lo.ToPtr("%test%"), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" NOT ILIKE $1", + wantArgs: []interface{}{"%test%"}, + }, + { + name: "gt filter", + filter: filter.FilterString{ + Gt: lo.ToPtr("test"), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" > $1", + wantArgs: []interface{}{"test"}, + }, + { + name: "gte filter", + filter: filter.FilterString{ + Gte: lo.ToPtr("test"), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" >= $1", + wantArgs: []interface{}{"test"}, + }, + { + name: "lt filter", + filter: filter.FilterString{ + Lt: lo.ToPtr("test"), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" < $1", + wantArgs: []interface{}{"test"}, + }, + { + name: "lte filter", + filter: filter.FilterString{ + Lte: lo.ToPtr("test"), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" <= $1", + wantArgs: []interface{}{"test"}, + }, + { + name: "and filter", + filter: filter.FilterString{ + And: &[]filter.FilterString{ + {Eq: lo.ToPtr("test1")}, + {Eq: lo.ToPtr("test2")}, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 AND \"test_field\" = $2", + wantArgs: []interface{}{"test1", "test2"}, + }, + { + name: "or filter", + filter: filter.FilterString{ + Or: &[]filter.FilterString{ + {Eq: lo.ToPtr("test1")}, + {Eq: lo.ToPtr("test2")}, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 OR \"test_field\" = $2", + wantArgs: []interface{}{"test1", "test2"}, + }, + { + name: "nested and filter", + filter: filter.FilterString{ + And: &[]filter.FilterString{ + { + And: &[]filter.FilterString{ + {Eq: lo.ToPtr("test1")}, + {Ne: lo.ToPtr("test2")}, + }, + }, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 AND \"test_field\" <> $2", + wantArgs: []interface{}{"test1", "test2"}, + }, + { + name: "nested or filter", + filter: filter.FilterString{ + Or: &[]filter.FilterString{ + { + Or: &[]filter.FilterString{ + {Eq: lo.ToPtr("test1")}, + {Ne: lo.ToPtr("test2")}, + }, + }, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 OR \"test_field\" <> $2", + wantArgs: []interface{}{"test1", "test2"}, + }, + { + name: "mixed nested and/or filter", + filter: filter.FilterString{ + And: &[]filter.FilterString{ + { + Or: &[]filter.FilterString{ + {Eq: lo.ToPtr("test1")}, + {Like: lo.ToPtr("%test%")}, + }, + }, + { + And: &[]filter.FilterString{ + {Ne: lo.ToPtr("test2")}, + }, + }, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "(\"test_field\" = $1 OR \"test_field\" LIKE $2) AND \"test_field\" <> $3", + wantArgs: []interface{}{"test1", "%test%", "test2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pred := tt.filter.SelectWherePredicate(tt.field) + if tt.wantNil { + assert.Nil(t, pred, "predicate should be nil for empty filter") + return + } + assert.NotNil(t, pred, "predicate should not be nil") + + // Verify the actual SQL and arguments + sqlStr, args := buildSQLFromPredicate(pred) + if tt.wantSQL != "" { + assert.Equal(t, tt.wantSQL, sqlStr, "SQL should match expected value") + assert.Equal(t, tt.wantArgs, args, "SQL arguments should match expected values") + } + }) + } +} + +func TestFilterInteger_SelectWherePredicate(t *testing.T) { + tests := []struct { + name string + filter filter.FilterInteger + field string + wantNil bool + wantSQL string + wantArgs []interface{} + }{ + { + name: "empty filter", + filter: filter.FilterInteger{}, + field: "test_field", + wantNil: true, + wantSQL: "", + wantArgs: nil, + }, + { + name: "eq filter", + filter: filter.FilterInteger{ + Eq: lo.ToPtr(42), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1", + wantArgs: []interface{}{42}, + }, + { + name: "ne filter", + filter: filter.FilterInteger{ + Ne: lo.ToPtr(42), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" <> $1", + wantArgs: []interface{}{42}, + }, + { + name: "gt filter", + filter: filter.FilterInteger{ + Gt: lo.ToPtr(42), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" > $1", + wantArgs: []interface{}{42}, + }, + { + name: "gte filter", + filter: filter.FilterInteger{ + Gte: lo.ToPtr(42), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" >= $1", + wantArgs: []interface{}{42}, + }, + { + name: "lt filter", + filter: filter.FilterInteger{ + Lt: lo.ToPtr(42), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" < $1", + wantArgs: []interface{}{42}, + }, + { + name: "lte filter", + filter: filter.FilterInteger{ + Lte: lo.ToPtr(42), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" <= $1", + wantArgs: []interface{}{42}, + }, + { + name: "and filter", + filter: filter.FilterInteger{ + And: &[]filter.FilterInteger{ + {Eq: lo.ToPtr(42)}, + {Gt: lo.ToPtr(10)}, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 AND \"test_field\" > $2", + wantArgs: []interface{}{42, 10}, + }, + { + name: "or filter", + filter: filter.FilterInteger{ + Or: &[]filter.FilterInteger{ + {Eq: lo.ToPtr(42)}, + {Lt: lo.ToPtr(100)}, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 OR \"test_field\" < $2", + wantArgs: []interface{}{42, 100}, + }, + { + name: "nested and filter", + filter: filter.FilterInteger{ + And: &[]filter.FilterInteger{ + { + And: &[]filter.FilterInteger{ + {Eq: lo.ToPtr(42)}, + {Gt: lo.ToPtr(10)}, + }, + }, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 AND \"test_field\" > $2", + wantArgs: []interface{}{42, 10}, + }, + { + name: "nested or filter", + filter: filter.FilterInteger{ + Or: &[]filter.FilterInteger{ + { + Or: &[]filter.FilterInteger{ + {Eq: lo.ToPtr(42)}, + {Lt: lo.ToPtr(100)}, + }, + }, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 OR \"test_field\" < $2", + wantArgs: []interface{}{42, 100}, + }, + { + name: "mixed nested and/or filter", + filter: filter.FilterInteger{ + And: &[]filter.FilterInteger{ + { + Or: &[]filter.FilterInteger{ + {Eq: lo.ToPtr(42)}, + {Gt: lo.ToPtr(10)}, + }, + }, + { + And: &[]filter.FilterInteger{ + {Lt: lo.ToPtr(100)}, + }, + }, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "(\"test_field\" = $1 OR \"test_field\" > $2) AND \"test_field\" < $3", + wantArgs: []interface{}{42, 10, 100}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pred := tt.filter.SelectWherePredicate(tt.field) + if tt.wantNil { + assert.Nil(t, pred, "predicate should be nil for empty filter") + return + } + assert.NotNil(t, pred, "predicate should not be nil") + + // Verify the actual SQL and arguments + sqlStr, args := buildSQLFromPredicate(pred) + if tt.wantSQL != "" { + assert.Equal(t, tt.wantSQL, sqlStr, "SQL should match expected value") + assert.Equal(t, tt.wantArgs, args, "SQL arguments should match expected values") + } + }) + } +} + +func TestFilterFloat_SelectWherePredicate(t *testing.T) { + tests := []struct { + name string + filter filter.FilterFloat + field string + wantNil bool + wantSQL string + wantArgs []interface{} + }{ + { + name: "empty filter", + filter: filter.FilterFloat{}, + field: "test_field", + wantNil: true, + wantSQL: "", + wantArgs: nil, + }, + { + name: "eq filter", + filter: filter.FilterFloat{ + Eq: lo.ToPtr(42.5), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1", + wantArgs: []interface{}{42.5}, + }, + { + name: "ne filter", + filter: filter.FilterFloat{ + Ne: lo.ToPtr(42.5), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" <> $1", + wantArgs: []interface{}{42.5}, + }, + { + name: "gt filter", + filter: filter.FilterFloat{ + Gt: lo.ToPtr(42.5), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" > $1", + wantArgs: []interface{}{42.5}, + }, + { + name: "gte filter", + filter: filter.FilterFloat{ + Gte: lo.ToPtr(42.5), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" >= $1", + wantArgs: []interface{}{42.5}, + }, + { + name: "lt filter", + filter: filter.FilterFloat{ + Lt: lo.ToPtr(42.5), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" < $1", + wantArgs: []interface{}{42.5}, + }, + { + name: "lte filter", + filter: filter.FilterFloat{ + Lte: lo.ToPtr(42.5), + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" <= $1", + wantArgs: []interface{}{42.5}, + }, + { + name: "and filter", + filter: filter.FilterFloat{ + And: &[]filter.FilterFloat{ + {Eq: lo.ToPtr(42.5)}, + {Gt: lo.ToPtr(10.5)}, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 AND \"test_field\" > $2", + wantArgs: []interface{}{42.5, 10.5}, + }, + { + name: "or filter", + filter: filter.FilterFloat{ + Or: &[]filter.FilterFloat{ + {Eq: lo.ToPtr(42.5)}, + {Lt: lo.ToPtr(100.5)}, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 OR \"test_field\" < $2", + wantArgs: []interface{}{42.5, 100.5}, + }, + { + name: "nested and filter", + filter: filter.FilterFloat{ + And: &[]filter.FilterFloat{ + { + And: &[]filter.FilterFloat{ + {Eq: lo.ToPtr(42.5)}, + {Gt: lo.ToPtr(10.5)}, + }, + }, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 AND \"test_field\" > $2", + wantArgs: []interface{}{42.5, 10.5}, + }, + { + name: "nested or filter", + filter: filter.FilterFloat{ + Or: &[]filter.FilterFloat{ + { + Or: &[]filter.FilterFloat{ + {Eq: lo.ToPtr(42.5)}, + {Lt: lo.ToPtr(100.5)}, + }, + }, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "\"test_field\" = $1 OR \"test_field\" < $2", + wantArgs: []interface{}{42.5, 100.5}, + }, + { + name: "mixed nested and/or filter", + filter: filter.FilterFloat{ + And: &[]filter.FilterFloat{ + { + Or: &[]filter.FilterFloat{ + {Eq: lo.ToPtr(42.5)}, + {Gt: lo.ToPtr(10.5)}, + }, + }, + { + And: &[]filter.FilterFloat{ + {Lt: lo.ToPtr(100.5)}, + }, + }, + }, + }, + field: "test_field", + wantNil: false, + wantSQL: "(\"test_field\" = $1 OR \"test_field\" > $2) AND \"test_field\" < $3", + wantArgs: []interface{}{42.5, 10.5, 100.5}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pred := tt.filter.SelectWherePredicate(tt.field) + if tt.wantNil { + assert.Nil(t, pred, "predicate should be nil for empty filter") + return + } + assert.NotNil(t, pred, "predicate should not be nil") + + // Verify the actual SQL and arguments + sqlStr, args := buildSQLFromPredicate(pred) + if tt.wantSQL != "" { + assert.Equal(t, tt.wantSQL, sqlStr, "SQL should match expected value") + assert.Equal(t, tt.wantArgs, args, "SQL arguments should match expected values") + } + }) + } +} + +func TestFilterTime_SelectWherePredicate(t *testing.T) { + // Use fixed times for predictable test results + now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + nowPlus24h := now.Add(24 * time.Hour) + nowPlus48h := now.Add(48 * time.Hour) + + tests := []struct { + name string + filter filter.FilterTime + field string + wantNil bool + wantSQL string + wantArgs []interface{} + }{ + { + name: "empty filter", + filter: filter.FilterTime{}, + field: "created_at", + wantNil: true, + wantSQL: "", + wantArgs: nil, + }, + { + name: "gt filter", + filter: filter.FilterTime{ + Gt: lo.ToPtr(now), + }, + field: "created_at", + wantNil: false, + wantSQL: "\"created_at\" > $1", + wantArgs: []interface{}{now}, + }, + { + name: "gte filter", + filter: filter.FilterTime{ + Gte: lo.ToPtr(now), + }, + field: "created_at", + wantNil: false, + wantSQL: "\"created_at\" >= $1", + wantArgs: []interface{}{now}, + }, + { + name: "lt filter", + filter: filter.FilterTime{ + Lt: lo.ToPtr(now), + }, + field: "created_at", + wantNil: false, + wantSQL: "\"created_at\" < $1", + wantArgs: []interface{}{now}, + }, + { + name: "lte filter", + filter: filter.FilterTime{ + Lte: lo.ToPtr(now), + }, + field: "created_at", + wantNil: false, + wantSQL: "\"created_at\" <= $1", + wantArgs: []interface{}{now}, + }, + { + name: "and filter", + filter: filter.FilterTime{ + And: &[]filter.FilterTime{ + {Gt: lo.ToPtr(now)}, + {Lt: lo.ToPtr(nowPlus24h)}, + }, + }, + field: "created_at", + wantNil: false, + wantSQL: "\"created_at\" > $1 AND \"created_at\" < $2", + wantArgs: []interface{}{now, nowPlus24h}, + }, + { + name: "or filter", + filter: filter.FilterTime{ + Or: &[]filter.FilterTime{ + {Gt: lo.ToPtr(now)}, + {Lt: lo.ToPtr(nowPlus24h)}, + }, + }, + field: "created_at", + wantNil: false, + wantSQL: "\"created_at\" > $1 OR \"created_at\" < $2", + wantArgs: []interface{}{now, nowPlus24h}, + }, + { + name: "nested and filter", + filter: filter.FilterTime{ + And: &[]filter.FilterTime{ + { + And: &[]filter.FilterTime{ + {Gt: lo.ToPtr(now)}, + {Lt: lo.ToPtr(nowPlus24h)}, + }, + }, + }, + }, + field: "created_at", + wantNil: false, + wantSQL: "\"created_at\" > $1 AND \"created_at\" < $2", + wantArgs: []interface{}{now, nowPlus24h}, + }, + { + name: "nested or filter", + filter: filter.FilterTime{ + Or: &[]filter.FilterTime{ + { + Or: &[]filter.FilterTime{ + {Gt: lo.ToPtr(now)}, + {Lt: lo.ToPtr(nowPlus24h)}, + }, + }, + }, + }, + field: "created_at", + wantNil: false, + wantSQL: "\"created_at\" > $1 OR \"created_at\" < $2", + wantArgs: []interface{}{now, nowPlus24h}, + }, + { + name: "mixed nested and/or filter", + filter: filter.FilterTime{ + And: &[]filter.FilterTime{ + { + Or: &[]filter.FilterTime{ + {Gt: lo.ToPtr(now)}, + {Lte: lo.ToPtr(nowPlus24h)}, + }, + }, + { + And: &[]filter.FilterTime{ + {Lt: lo.ToPtr(nowPlus48h)}, + }, + }, + }, + }, + field: "created_at", + wantNil: false, + wantSQL: "(\"created_at\" > $1 OR \"created_at\" <= $2) AND \"created_at\" < $3", + wantArgs: []interface{}{now, nowPlus24h, nowPlus48h}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pred := tt.filter.SelectWherePredicate(tt.field) + if tt.wantNil { + assert.Nil(t, pred, "predicate should be nil for empty filter") + return + } + assert.NotNil(t, pred, "predicate should not be nil") + + // Verify the actual SQL and arguments + sqlStr, args := buildSQLFromPredicate(pred) + if tt.wantSQL != "" { + assert.Equal(t, tt.wantSQL, sqlStr, "SQL should match expected value") + assert.Equal(t, tt.wantArgs, args, "SQL arguments should match expected values") + } + }) + } +} + +func TestFilterBoolean_SelectWherePredicate(t *testing.T) { + tests := []struct { + name string + filter filter.FilterBoolean + field string + wantNil bool + wantSQL string + wantArgs []interface{} + }{ + { + name: "empty filter", + filter: filter.FilterBoolean{}, + field: "test_field", + wantNil: true, + wantSQL: "", + wantArgs: nil, + }, + { + name: "eq filter true", + filter: filter.FilterBoolean{ + Eq: lo.ToPtr(true), + }, + field: "test_field", + wantNil: false, + // PostgreSQL optimizes "field = true" to just "field" + wantSQL: "\"test_field\"", + wantArgs: []interface{}(nil), + }, + { + name: "eq filter false", + filter: filter.FilterBoolean{ + Eq: lo.ToPtr(false), + }, + field: "test_field", + wantNil: false, + // PostgreSQL optimizes "field = false" to "NOT field" + wantSQL: "NOT \"test_field\"", + wantArgs: []interface{}(nil), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pred := tt.filter.SelectWherePredicate(tt.field) + if tt.wantNil { + assert.Nil(t, pred, "predicate should be nil for empty filter") + return + } + assert.NotNil(t, pred, "predicate should not be nil") + + // Verify the actual SQL and arguments + sqlStr, args := buildSQLFromPredicate(pred) + if tt.wantSQL != "" { + assert.Equal(t, tt.wantSQL, sqlStr, "SQL should match expected value") + assert.Equal(t, tt.wantArgs, args, "SQL arguments should match expected values") + } + }) + } +} From 40df682d8ae02dbc9b9086b7a0255f62d8f450cf Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Sat, 6 Dec 2025 01:18:59 +0100 Subject: [PATCH 04/18] feat: add list customer query params --- api/spec/src/v3/customers/operations.tsp | 40 ++ api/v3/api.gen.go | 725 ++++++++++++++++++++--- api/v3/handlers/convert.gen.go | 38 +- api/v3/handlers/convert.go | 12 +- api/v3/handlers/customer.go | 8 +- api/v3/openapi.yaml | 250 +++++++- 6 files changed, 940 insertions(+), 133 deletions(-) diff --git a/api/spec/src/v3/customers/operations.tsp b/api/spec/src/v3/customers/operations.tsp index afee232901..6fa013d964 100644 --- a/api/spec/src/v3/customers/operations.tsp +++ b/api/spec/src/v3/customers/operations.tsp @@ -13,6 +13,45 @@ using TypeSpec.OpenAPI; namespace Customers; +/** + * Query params for listing customers. + */ +@friendlyName("ListCustomersParams") +model ListCustomersParams { + /** + * Sort customers returned in the response. + * Supported sort attributes are: + * - `key` + * - `id` + * - `name` + * - `primary_email` + * - `created_at` (default) + * - `updated_at` + * - `deleted_at` + * + * The `asc` suffix is optional as the default sort order is ascending. + * The `desc` suffix is used to specify a descending order. + * Multiple sort attributes may be provided via a comma separated list. + */ + @query + sort?: Common.SortQuery; + + /** + * Filter customers returned in the response. + */ + @query(#{style: "deepObject"}) + filter?: { + id: Common.StringFieldFilter, + key: Common.StringFieldFilter, + name: Common.StringFieldFilter, + `usage_attribution.subject_keys`: Common.StringFieldFilter, + primary_email: Common.StringFieldFilter, + created_at: Common.DateTimeFieldFilter, + updated_at: Common.DateTimeFieldFilter, + deleted_at: Common.DateTimeFieldFilter, + } +} + interface CustomersOperations { @post @operationId("create-customer") @@ -40,6 +79,7 @@ interface CustomersOperations { @extension(Shared.InternalExtension, true) list( ...Common.CursorPageQuery, + ...ListCustomersParams, ): Shared.CursorPaginatedResponse | Common.ErrorResponses; @put diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index 78f713728a..8346f14cc9 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -360,6 +360,44 @@ type CustomersUsageAttributionKey = string // DateTime [RFC3339](https://tools.ietf.org/html/rfc3339) formatted date-time string in UTC. type DateTime = time.Time +// DateTimeFieldFilter Filters on the given datetime (RFC-3339) field value. +type DateTimeFieldFilter struct { + union json.RawMessage +} + +// DateTimeFieldFilter0 Value strictly equals given RFC-3339 formatted timestamp in UTC +type DateTimeFieldFilter0 = time.Time + +// DateTimeFieldFilter1 defines model for . +type DateTimeFieldFilter1 struct { + // Eq Value strictly equals given RFC-3339 formatted timestamp in UTC + Eq time.Time `json:"eq"` +} + +// DateTimeFieldFilter2 defines model for . +type DateTimeFieldFilter2 struct { + // Lt Value is less than the given RFC-3339 formatted timestamp in UTC + Lt time.Time `json:"lt"` +} + +// DateTimeFieldFilter3 defines model for . +type DateTimeFieldFilter3 struct { + // Lte Value is less than or equal to the given RFC-3339 formatted timestamp in UTC + Lte time.Time `json:"lte"` +} + +// DateTimeFieldFilter4 defines model for . +type DateTimeFieldFilter4 struct { + // Lt Value is greater than the given RFC-3339 formatted timestamp in UTC + Lt *time.Time `json:"lt,omitempty"` +} + +// DateTimeFieldFilter5 defines model for . +type DateTimeFieldFilter5 struct { + // Lte Value is greater than or equal to the given RFC-3339 formatted timestamp in UTC + Lte *time.Time `json:"lte,omitempty"` +} + // ForbiddenError defines model for ForbiddenError. type ForbiddenError struct { Detail interface{} `json:"detail"` @@ -562,6 +600,50 @@ type NotFoundError struct { // ResourceKey A key is a unique string that is used to identify a resource. type ResourceKey = string +// SortQuery The `asc` suffix is optional as the default sort order is ascending. +// The `desc` suffix is used to specify a descending order. +// Multiple sort attributes may be provided via a comma separated list. +// JSONPath notation may be used to specify a sub-attribute (eg: 'foo.bar desc'). +type SortQuery = string + +// StringFieldContainsFilter Filters on the given string field value by fuzzy match. +type StringFieldContainsFilter struct { + Contains string `json:"contains"` +} + +// StringFieldEqualsFilter Filters on the given string field value by exact match. +type StringFieldEqualsFilter struct { + union json.RawMessage +} + +// StringFieldEqualsFilter0 defines model for . +type StringFieldEqualsFilter0 = string + +// StringFieldEqualsFilter1 defines model for . +type StringFieldEqualsFilter1 struct { + Eq string `json:"eq"` +} + +// StringFieldFilter Filters on the given string field value by either exact or fuzzy match. +type StringFieldFilter struct { + union json.RawMessage +} + +// StringFieldNEQFilter Filters on the given string field value by exact match inequality. +type StringFieldNEQFilter struct { + Neq string `json:"neq"` +} + +// StringFieldOContainsFilter Returns entities that fuzzy-match any of the comma-delimited phrases in the filter string. +type StringFieldOContainsFilter struct { + Ocontains string `json:"ocontains"` +} + +// StringFieldOEQFilter Returns entities that exact match any of the comma-delimited phrases in the filter string. +type StringFieldOEQFilter struct { + Oeq string `json:"oeq"` +} + // ULID ULID (Universally Unique Lexicographically Sortable Identifier). type ULID = string @@ -641,6 +723,39 @@ type UpsertCustomerRequest struct { // CursorPageQuery defines model for CursorPageQuery. type CursorPageQuery = CursorPageParameters +// ListCustomersParamsFilter defines model for ListCustomersParams.filter. +type ListCustomersParamsFilter struct { + // CreatedAt Filters on the given datetime (RFC-3339) field value. + CreatedAt DateTimeFieldFilter `json:"created_at"` + + // DeletedAt Filters on the given datetime (RFC-3339) field value. + DeletedAt DateTimeFieldFilter `json:"deleted_at"` + + // Id Filters on the given string field value by either exact or fuzzy match. + Id StringFieldFilter `json:"id"` + + // Key Filters on the given string field value by either exact or fuzzy match. + Key StringFieldFilter `json:"key"` + + // Name Filters on the given string field value by either exact or fuzzy match. + Name StringFieldFilter `json:"name"` + + // PrimaryEmail Filters on the given string field value by either exact or fuzzy match. + PrimaryEmail StringFieldFilter `json:"primary_email"` + + // UpdatedAt Filters on the given datetime (RFC-3339) field value. + UpdatedAt DateTimeFieldFilter `json:"updated_at"` + + // UsageAttributionSubjectKeys Filters on the given string field value by either exact or fuzzy match. + UsageAttributionSubjectKeys StringFieldFilter `json:"usage_attribution.subject_keys"` +} + +// ListCustomersParamsSort The `asc` suffix is optional as the default sort order is ascending. +// The `desc` suffix is used to specify a descending order. +// Multiple sort attributes may be provided via a comma separated list. +// JSONPath notation may be used to specify a sub-attribute (eg: 'foo.bar desc'). +type ListCustomersParamsSort = SortQuery + // BadRequest defines model for BadRequest. type BadRequest = BadRequestError @@ -663,6 +778,24 @@ type Unauthorized = UnauthorizedError type ListCustomersParams struct { // Page Determines which page of the collection to retrieve. Page *CursorPageQuery `form:"page,omitempty" json:"page,omitempty"` + + // Sort Sort customers returned in the response. + // Supported sort attributes are: + // - `key` + // - `id` + // - `name` + // - `primary_email` + // - `created_at` (default) + // - `updated_at` + // - `deleted_at` + // + // The `asc` suffix is optional as the default sort order is ascending. + // The `desc` suffix is used to specify a descending order. + // Multiple sort attributes may be provided via a comma separated list. + Sort *ListCustomersParamsSort `form:"sort,omitempty" json:"sort,omitempty"` + + // Filter Filter customers returned in the response. + Filter *ListCustomersParamsFilter `json:"filter,omitempty"` } // IngestMeteringEventsApplicationCloudeventsBatchPlusJSONBody defines parameters for IngestMeteringEvents. @@ -703,6 +836,172 @@ type IngestMeteringEventsJSONRequestBody IngestMeteringEventsJSONBody // CreateMeterJSONRequestBody defines body for CreateMeter for application/json ContentType. type CreateMeterJSONRequestBody = CreateMeterRequest +// AsDateTimeFieldFilter0 returns the union data inside the DateTimeFieldFilter as a DateTimeFieldFilter0 +func (t DateTimeFieldFilter) AsDateTimeFieldFilter0() (DateTimeFieldFilter0, error) { + var body DateTimeFieldFilter0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDateTimeFieldFilter0 overwrites any union data inside the DateTimeFieldFilter as the provided DateTimeFieldFilter0 +func (t *DateTimeFieldFilter) FromDateTimeFieldFilter0(v DateTimeFieldFilter0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDateTimeFieldFilter0 performs a merge with any union data inside the DateTimeFieldFilter, using the provided DateTimeFieldFilter0 +func (t *DateTimeFieldFilter) MergeDateTimeFieldFilter0(v DateTimeFieldFilter0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDateTimeFieldFilter1 returns the union data inside the DateTimeFieldFilter as a DateTimeFieldFilter1 +func (t DateTimeFieldFilter) AsDateTimeFieldFilter1() (DateTimeFieldFilter1, error) { + var body DateTimeFieldFilter1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDateTimeFieldFilter1 overwrites any union data inside the DateTimeFieldFilter as the provided DateTimeFieldFilter1 +func (t *DateTimeFieldFilter) FromDateTimeFieldFilter1(v DateTimeFieldFilter1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDateTimeFieldFilter1 performs a merge with any union data inside the DateTimeFieldFilter, using the provided DateTimeFieldFilter1 +func (t *DateTimeFieldFilter) MergeDateTimeFieldFilter1(v DateTimeFieldFilter1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDateTimeFieldFilter2 returns the union data inside the DateTimeFieldFilter as a DateTimeFieldFilter2 +func (t DateTimeFieldFilter) AsDateTimeFieldFilter2() (DateTimeFieldFilter2, error) { + var body DateTimeFieldFilter2 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDateTimeFieldFilter2 overwrites any union data inside the DateTimeFieldFilter as the provided DateTimeFieldFilter2 +func (t *DateTimeFieldFilter) FromDateTimeFieldFilter2(v DateTimeFieldFilter2) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDateTimeFieldFilter2 performs a merge with any union data inside the DateTimeFieldFilter, using the provided DateTimeFieldFilter2 +func (t *DateTimeFieldFilter) MergeDateTimeFieldFilter2(v DateTimeFieldFilter2) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDateTimeFieldFilter3 returns the union data inside the DateTimeFieldFilter as a DateTimeFieldFilter3 +func (t DateTimeFieldFilter) AsDateTimeFieldFilter3() (DateTimeFieldFilter3, error) { + var body DateTimeFieldFilter3 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDateTimeFieldFilter3 overwrites any union data inside the DateTimeFieldFilter as the provided DateTimeFieldFilter3 +func (t *DateTimeFieldFilter) FromDateTimeFieldFilter3(v DateTimeFieldFilter3) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDateTimeFieldFilter3 performs a merge with any union data inside the DateTimeFieldFilter, using the provided DateTimeFieldFilter3 +func (t *DateTimeFieldFilter) MergeDateTimeFieldFilter3(v DateTimeFieldFilter3) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDateTimeFieldFilter4 returns the union data inside the DateTimeFieldFilter as a DateTimeFieldFilter4 +func (t DateTimeFieldFilter) AsDateTimeFieldFilter4() (DateTimeFieldFilter4, error) { + var body DateTimeFieldFilter4 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDateTimeFieldFilter4 overwrites any union data inside the DateTimeFieldFilter as the provided DateTimeFieldFilter4 +func (t *DateTimeFieldFilter) FromDateTimeFieldFilter4(v DateTimeFieldFilter4) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDateTimeFieldFilter4 performs a merge with any union data inside the DateTimeFieldFilter, using the provided DateTimeFieldFilter4 +func (t *DateTimeFieldFilter) MergeDateTimeFieldFilter4(v DateTimeFieldFilter4) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDateTimeFieldFilter5 returns the union data inside the DateTimeFieldFilter as a DateTimeFieldFilter5 +func (t DateTimeFieldFilter) AsDateTimeFieldFilter5() (DateTimeFieldFilter5, error) { + var body DateTimeFieldFilter5 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDateTimeFieldFilter5 overwrites any union data inside the DateTimeFieldFilter as the provided DateTimeFieldFilter5 +func (t *DateTimeFieldFilter) FromDateTimeFieldFilter5(v DateTimeFieldFilter5) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDateTimeFieldFilter5 performs a merge with any union data inside the DateTimeFieldFilter, using the provided DateTimeFieldFilter5 +func (t *DateTimeFieldFilter) MergeDateTimeFieldFilter5(v DateTimeFieldFilter5) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t DateTimeFieldFilter) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *DateTimeFieldFilter) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // AsInvalidParameterStandard returns the union data inside the InvalidParameters_Item as a InvalidParameterStandard func (t InvalidParameters_Item) AsInvalidParameterStandard() (InvalidParameterStandard, error) { var body InvalidParameterStandard @@ -843,6 +1142,208 @@ func (t *InvalidParameters_Item) UnmarshalJSON(b []byte) error { return err } +// AsStringFieldEqualsFilter0 returns the union data inside the StringFieldEqualsFilter as a StringFieldEqualsFilter0 +func (t StringFieldEqualsFilter) AsStringFieldEqualsFilter0() (StringFieldEqualsFilter0, error) { + var body StringFieldEqualsFilter0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStringFieldEqualsFilter0 overwrites any union data inside the StringFieldEqualsFilter as the provided StringFieldEqualsFilter0 +func (t *StringFieldEqualsFilter) FromStringFieldEqualsFilter0(v StringFieldEqualsFilter0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeStringFieldEqualsFilter0 performs a merge with any union data inside the StringFieldEqualsFilter, using the provided StringFieldEqualsFilter0 +func (t *StringFieldEqualsFilter) MergeStringFieldEqualsFilter0(v StringFieldEqualsFilter0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsStringFieldEqualsFilter1 returns the union data inside the StringFieldEqualsFilter as a StringFieldEqualsFilter1 +func (t StringFieldEqualsFilter) AsStringFieldEqualsFilter1() (StringFieldEqualsFilter1, error) { + var body StringFieldEqualsFilter1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStringFieldEqualsFilter1 overwrites any union data inside the StringFieldEqualsFilter as the provided StringFieldEqualsFilter1 +func (t *StringFieldEqualsFilter) FromStringFieldEqualsFilter1(v StringFieldEqualsFilter1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeStringFieldEqualsFilter1 performs a merge with any union data inside the StringFieldEqualsFilter, using the provided StringFieldEqualsFilter1 +func (t *StringFieldEqualsFilter) MergeStringFieldEqualsFilter1(v StringFieldEqualsFilter1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t StringFieldEqualsFilter) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *StringFieldEqualsFilter) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsStringFieldEqualsFilter returns the union data inside the StringFieldFilter as a StringFieldEqualsFilter +func (t StringFieldFilter) AsStringFieldEqualsFilter() (StringFieldEqualsFilter, error) { + var body StringFieldEqualsFilter + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStringFieldEqualsFilter overwrites any union data inside the StringFieldFilter as the provided StringFieldEqualsFilter +func (t *StringFieldFilter) FromStringFieldEqualsFilter(v StringFieldEqualsFilter) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeStringFieldEqualsFilter performs a merge with any union data inside the StringFieldFilter, using the provided StringFieldEqualsFilter +func (t *StringFieldFilter) MergeStringFieldEqualsFilter(v StringFieldEqualsFilter) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsStringFieldContainsFilter returns the union data inside the StringFieldFilter as a StringFieldContainsFilter +func (t StringFieldFilter) AsStringFieldContainsFilter() (StringFieldContainsFilter, error) { + var body StringFieldContainsFilter + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStringFieldContainsFilter overwrites any union data inside the StringFieldFilter as the provided StringFieldContainsFilter +func (t *StringFieldFilter) FromStringFieldContainsFilter(v StringFieldContainsFilter) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeStringFieldContainsFilter performs a merge with any union data inside the StringFieldFilter, using the provided StringFieldContainsFilter +func (t *StringFieldFilter) MergeStringFieldContainsFilter(v StringFieldContainsFilter) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsStringFieldOContainsFilter returns the union data inside the StringFieldFilter as a StringFieldOContainsFilter +func (t StringFieldFilter) AsStringFieldOContainsFilter() (StringFieldOContainsFilter, error) { + var body StringFieldOContainsFilter + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStringFieldOContainsFilter overwrites any union data inside the StringFieldFilter as the provided StringFieldOContainsFilter +func (t *StringFieldFilter) FromStringFieldOContainsFilter(v StringFieldOContainsFilter) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeStringFieldOContainsFilter performs a merge with any union data inside the StringFieldFilter, using the provided StringFieldOContainsFilter +func (t *StringFieldFilter) MergeStringFieldOContainsFilter(v StringFieldOContainsFilter) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsStringFieldOEQFilter returns the union data inside the StringFieldFilter as a StringFieldOEQFilter +func (t StringFieldFilter) AsStringFieldOEQFilter() (StringFieldOEQFilter, error) { + var body StringFieldOEQFilter + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStringFieldOEQFilter overwrites any union data inside the StringFieldFilter as the provided StringFieldOEQFilter +func (t *StringFieldFilter) FromStringFieldOEQFilter(v StringFieldOEQFilter) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeStringFieldOEQFilter performs a merge with any union data inside the StringFieldFilter, using the provided StringFieldOEQFilter +func (t *StringFieldFilter) MergeStringFieldOEQFilter(v StringFieldOEQFilter) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsStringFieldNEQFilter returns the union data inside the StringFieldFilter as a StringFieldNEQFilter +func (t StringFieldFilter) AsStringFieldNEQFilter() (StringFieldNEQFilter, error) { + var body StringFieldNEQFilter + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStringFieldNEQFilter overwrites any union data inside the StringFieldFilter as the provided StringFieldNEQFilter +func (t *StringFieldFilter) FromStringFieldNEQFilter(v StringFieldNEQFilter) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeStringFieldNEQFilter performs a merge with any union data inside the StringFieldFilter, using the provided StringFieldNEQFilter +func (t *StringFieldFilter) MergeStringFieldNEQFilter(v StringFieldNEQFilter) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t StringFieldFilter) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *StringFieldFilter) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // ServerInterface represents all server handlers. type ServerInterface interface { // List customers @@ -966,6 +1467,22 @@ func (siw *ServerInterfaceWrapper) ListCustomers(w http.ResponseWriter, r *http. return } + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", false, false, "sort", r.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err}) + return + } + + // ------------- Optional query parameter "filter" ------------- + + err = runtime.BindQueryParameter("deepObject", false, false, "filter", r.URL.Query(), ¶ms.Filter) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.ListCustomers(w, r, params) })) @@ -1321,102 +1838,118 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "srGDfQAA", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/handlers/convert.gen.go b/api/v3/handlers/convert.gen.go index 332576ce47..d18a572848 100644 --- a/api/v3/handlers/convert.gen.go +++ b/api/v3/handlers/convert.gen.go @@ -16,13 +16,7 @@ import ( ) func init() { - ConvertCreateCustomerRequest = func(context string, source v3.CreateCustomerRequest) customer.CreateCustomerInput { - var customerCreateCustomerInput customer.CreateCustomerInput - customerCreateCustomerInput.Namespace = NamespaceFromContext(context) - customerCreateCustomerInput.CustomerMutate = ConvertCreateCustomerToCustomerMutate(source) - return customerCreateCustomerInput - } - ConvertCreateCustomerToCustomerMutate = func(source v3.CreateCustomerRequest) customer.CustomerMutate { + ConvertCreateCustomerRequestToCustomerMutate = func(source v3.CreateCustomerRequest) customer.CustomerMutate { var customerCustomerMutate customer.CustomerMutate pString := source.Key customerCustomerMutate.Key = &pString @@ -38,7 +32,18 @@ func init() { customerCustomerMutate.Metadata = pV3LabelsToPModelsMetadata(source.Labels) return customerCustomerMutate } - ConvertCustomer = func(source customer.Customer) v3.BillingCustomer { + ConvertCustomerListResponse = func(source response.CursorPaginationResponse[customer.Customer]) v3.CustomerPaginatedResponse { + var v3CustomerPaginatedResponse v3.CustomerPaginatedResponse + if source.Data != nil { + v3CustomerPaginatedResponse.Data = make([]v3.BillingCustomer, len(source.Data)) + for i := 0; i < len(source.Data); i++ { + v3CustomerPaginatedResponse.Data[i] = ConvertCustomerRequestToBillingCustomer(source.Data[i]) + } + } + v3CustomerPaginatedResponse.Meta = responseCursorMetaToV3CursorMeta(source.Meta) + return v3CustomerPaginatedResponse + } + ConvertCustomerRequestToBillingCustomer = func(source customer.Customer) v3.BillingCustomer { var v3BillingCustomer v3.BillingCustomer v3BillingCustomer.BillingAddress = pModelsAddressToPV3BillingAddress(source.BillingAddress) v3BillingCustomer.CreatedAt = timeTimeToPTimeTime(source.ManagedResource.ManagedModel.CreatedAt) @@ -59,16 +64,11 @@ func init() { v3BillingCustomer.UsageAttribution = customerCustomerUsageAttributionToPV3BillingCustomerUsageAttribution(source.UsageAttribution) return v3BillingCustomer } - ConvertCustomerListResponse = func(source response.CursorPaginationResponse[customer.Customer]) v3.CustomerPaginatedResponse { - var v3CustomerPaginatedResponse v3.CustomerPaginatedResponse - if source.Data != nil { - v3CustomerPaginatedResponse.Data = make([]v3.BillingCustomer, len(source.Data)) - for i := 0; i < len(source.Data); i++ { - v3CustomerPaginatedResponse.Data[i] = ConvertCustomer(source.Data[i]) - } - } - v3CustomerPaginatedResponse.Meta = responseCursorMetaToV3CursorMeta(source.Meta) - return v3CustomerPaginatedResponse + ConvertFromCreateCustomerRequestToCreateCustomerInput = func(context string, source v3.CreateCustomerRequest) customer.CreateCustomerInput { + var customerCreateCustomerInput customer.CreateCustomerInput + customerCreateCustomerInput.Namespace = NamespaceFromContext(context) + customerCreateCustomerInput.CustomerMutate = ConvertCreateCustomerRequestToCustomerMutate(source) + return customerCreateCustomerInput } ConvertMeter = func(source meter.Meter) (v3.Meter, error) { var v3Meter v3.Meter @@ -113,7 +113,7 @@ func init() { } return v3MeterAggregation, nil } - ConvertMetersListResponse = func(source response.CursorPaginationResponse[meter.Meter]) (v3.MeterPaginatedResponse, error) { + ConvertMeterListResponse = func(source response.CursorPaginationResponse[meter.Meter]) (v3.MeterPaginatedResponse, error) { var v3MeterPaginatedResponse v3.MeterPaginatedResponse if source.Data != nil { v3MeterPaginatedResponse.Data = make([]v3.Meter, len(source.Data)) diff --git a/api/v3/handlers/convert.go b/api/v3/handlers/convert.go index fd8c2b7df5..3cd62dd3f5 100644 --- a/api/v3/handlers/convert.go +++ b/api/v3/handlers/convert.go @@ -23,7 +23,7 @@ var ( // goverter:context namespace // goverter:map Namespace | NamespaceFromContext // goverter:map . CustomerMutate - ConvertCreateCustomerRequest func(namespace string, createCustomerRequest api.CreateCustomerRequest) customer.CreateCustomerInput + ConvertFromCreateCustomerRequestToCreateCustomerInput func(namespace string, createCustomerRequest api.CreateCustomerRequest) customer.CreateCustomerInput // goverter:map Metadata Labels // goverter:map ManagedResource.ID Id // goverter:map ManagedResource.Description Description @@ -31,11 +31,11 @@ var ( // goverter:map ManagedResource.ManagedModel.CreatedAt CreatedAt // goverter:map ManagedResource.ManagedModel.UpdatedAt UpdatedAt // goverter:map ManagedResource.ManagedModel.DeletedAt DeletedAt - ConvertCustomer func(customer.Customer) api.BillingCustomer + ConvertCustomerRequestToBillingCustomer func(customer.Customer) api.BillingCustomer // goverter:map Labels Metadata // goverter:ignore Annotation - ConvertCreateCustomerToCustomerMutate func(createCustomerRequest api.CreateCustomerRequest) customer.CustomerMutate - ConvertCustomerListResponse func(customers response.CursorPaginationResponse[customer.Customer]) api.CustomerPaginatedResponse + ConvertCreateCustomerRequestToCustomerMutate func(createCustomerRequest api.CreateCustomerRequest) customer.CustomerMutate + ConvertCustomerListResponse func(customers response.CursorPaginationResponse[customer.Customer]) api.CustomerPaginatedResponse // goverter:map Metadata Labels // goverter:map GroupBy Dimensions // goverter:map EventType EventTypeFilter @@ -47,8 +47,8 @@ var ( // goverter:map ManagedResource.ManagedModel.DeletedAt DeletedAt ConvertMeter func(meter.Meter) (api.Meter, error) // goverter:enum:unknown @error - ConvertMeterAggregation func(aggregation meter.MeterAggregation) (api.MeterAggregation, error) - ConvertMetersListResponse func(meters response.CursorPaginationResponse[meter.Meter]) (api.MeterPaginatedResponse, error) + ConvertMeterAggregation func(aggregation meter.MeterAggregation) (api.MeterAggregation, error) + ConvertMeterListResponse func(meters response.CursorPaginationResponse[meter.Meter]) (api.MeterPaginatedResponse, error) ) //goverter:context namespace diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index b58fa92ff1..bb9db7f0bb 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -72,7 +72,7 @@ func (h *customerHandler) ListCustomers() ListCustomersHandler { customers := lo.Map(resp.Items, func(item customer.Customer, _ int) Customer { return Customer{ - BillingCustomer: ConvertCustomer(item), + BillingCustomer: ConvertCustomerRequestToBillingCustomer(item), } }) @@ -110,7 +110,7 @@ func (h *customerHandler) CreateCustomer() CreateCustomerHandler { return CreateCustomerRequest{}, err } - req := ConvertCreateCustomerRequest(ns, body) + req := ConvertFromCreateCustomerRequestToCreateCustomerInput(ns, body) return req, nil }, @@ -124,7 +124,7 @@ func (h *customerHandler) CreateCustomer() CreateCustomerHandler { return CreateCustomerResponse{}, fmt.Errorf("failed to create customer") } - return ConvertCustomer(*customer), nil + return ConvertCustomerRequestToBillingCustomer(*customer), nil }, commonhttp.JSONResponseEncoderWithStatus[CreateCustomerResponse](http.StatusCreated), httptransport.AppendOptions( @@ -168,7 +168,7 @@ func (h *customerHandler) GetCustomer() GetCustomerHandler { return GetCustomerResponse{}, fmt.Errorf("failed to get customer") } - return ConvertCustomer(*cus), nil + return ConvertCustomerRequestToBillingCustomer(*cus), nil }, commonhttp.JSONResponseEncoderWithStatus[GetCustomerResponse](http.StatusOK), httptransport.AppendOptions( diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index c08a435315..2c761ac0b8 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -60,6 +60,8 @@ paths: summary: List customers parameters: - $ref: '#/components/parameters/CursorPageQuery' + - $ref: '#/components/parameters/ListCustomersParams.sort' + - $ref: '#/components/parameters/ListCustomersParams.filter' responses: '200': description: Cursor paginated response. @@ -343,6 +345,71 @@ paths: tags: - Meters components: + parameters: + ListCustomersParams.filter: + name: filter + in: query + required: false + description: Filter customers returned in the response. + schema: + type: object + properties: + id: + $ref: '#/components/schemas/StringFieldFilter' + key: + $ref: '#/components/schemas/StringFieldFilter' + name: + $ref: '#/components/schemas/StringFieldFilter' + usage_attribution.subject_keys: + $ref: '#/components/schemas/StringFieldFilter' + primary_email: + $ref: '#/components/schemas/StringFieldFilter' + created_at: + $ref: '#/components/schemas/DateTimeFieldFilter' + updated_at: + $ref: '#/components/schemas/DateTimeFieldFilter' + deleted_at: + $ref: '#/components/schemas/DateTimeFieldFilter' + required: + - id + - key + - name + - usage_attribution.subject_keys + - primary_email + - created_at + - updated_at + - deleted_at + explode: false + style: deepObject + ListCustomersParams.sort: + name: sort + in: query + required: false + description: |- + Sort customers returned in the response. + Supported sort attributes are: + - `key` + - `id` + - `name` + - `primary_email` + - `created_at` (default) + - `updated_at` + - `deleted_at` + + The `asc` suffix is optional as the default sort order is ascending. + The `desc` suffix is used to specify a descending order. + Multiple sort attributes may be provided via a comma separated list. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + CursorPageQuery: + name: page + description: Determines which page of the collection to retrieve. + required: false + in: query + schema: + $ref: '#/components/schemas/CursorPageParameters' schemas: BillingAddress: type: object @@ -934,6 +1001,181 @@ components: type: string description: Cursor param specifying the page (i.e. the previous page) of data returned. example: ewogICJpZCI6ICJoZWxsbyB3b3JsZCIKfQ + SortQuery: + title: SortQuery + type: string + example: created_at desc + description: | + The `asc` suffix is optional as the default sort order is ascending. + The `desc` suffix is used to specify a descending order. + Multiple sort attributes may be provided via a comma separated list. + JSONPath notation may be used to specify a sub-attribute (eg: 'foo.bar desc'). + StringFieldEqualsFilter: + title: StringFieldEqualsFilter + description: Filters on the given string field value by exact match. + oneOf: + - type: string + - type: object + title: StringFieldEqualsComparison + additionalProperties: false + properties: + eq: + type: string + required: + - eq + x-examples: + example-1: equals-some-value + example-2: + eq: some-value + StringFieldContainsFilter: + title: StringFieldContainsFilter + description: Filters on the given string field value by fuzzy match. + type: object + additionalProperties: false + properties: + contains: + type: string + required: + - contains + x-examples: + example-1: + contains: some-value + StringFieldOContainsFilter: + title: StringFieldOContainsFilter + description: Returns entities that fuzzy-match any of the comma-delimited phrases in the filter string. + type: object + additionalProperties: false + properties: + ocontains: + type: string + required: + - ocontains + x-examples: + example-1: + ocontains: this-value,or-that-value + StringFieldOEQFilter: + title: StringFieldOEQFilter + description: Returns entities that exact match any of the comma-delimited phrases in the filter string. + type: object + additionalProperties: false + properties: + oeq: + type: string + required: + - oeq + x-examples: + example-1: + oeq: some-value,some-other-value + StringFieldNEQFilter: + title: StringFieldNEQFilter + description: Filters on the given string field value by exact match inequality. + type: object + additionalProperties: false + properties: + neq: + type: string + required: + - neq + x-examples: + example-1: + neq: not-this-value + StringFieldFilter: + title: StringFieldFilter + description: Filters on the given string field value by either exact or fuzzy match. + oneOf: + - $ref: '#/components/schemas/StringFieldEqualsFilter' + - $ref: '#/components/schemas/StringFieldContainsFilter' + - $ref: '#/components/schemas/StringFieldOContainsFilter' + - $ref: '#/components/schemas/StringFieldOEQFilter' + - $ref: '#/components/schemas/StringFieldNEQFilter' + x-examples: + example-1: equals-some-value + example-2: + eq: some-value + example-3: + contains: some-value + example-4: + ocontains: some-potential,value + example-5: + oeq: some-potential,value + example-6: + neq: not-this-value + DateTimeFieldFilter: + title: DateTimeFieldFilter + description: Filters on the given datetime (RFC-3339) field value. + oneOf: + - type: string + title: DateTimeFieldImplicitEqualsFilter + format: date-time + description: Value strictly equals given RFC-3339 formatted timestamp in UTC + example: '2022-03-30T07:20:50Z' + - type: object + title: DateTimeFieldEqualsFilter + additionalProperties: false + properties: + eq: + type: string + format: date-time + description: Value strictly equals given RFC-3339 formatted timestamp in UTC + example: '2022-03-30T07:20:50Z' + required: + - eq + - type: object + title: DateTimeFieldLTFilter + additionalProperties: false + properties: + lt: + type: string + format: date-time + description: Value is less than the given RFC-3339 formatted timestamp in UTC + example: '2022-03-30T07:20:50Z' + required: + - lt + - type: object + title: DateTimeFieldLTEFilter + additionalProperties: false + properties: + lte: + type: string + format: date-time + description: Value is less than or equal to the given RFC-3339 formatted timestamp in UTC + example: '2022-03-30T07:20:50Z' + required: + - lte + - type: object + title: DateTimeFieldGTFilter + additionalProperties: false + properties: + lt: + type: string + format: date-time + description: Value is greater than the given RFC-3339 formatted timestamp in UTC + example: '2022-03-30T07:20:50Z' + required: + - gt + - type: object + title: DateTimeFieldGTEFilter + additionalProperties: false + properties: + lte: + type: string + format: date-time + description: Value is greater than or equal to the given RFC-3339 formatted timestamp in UTC + example: '2022-03-30T07:20:50Z' + required: + - gte + x-examples: + datetime_field_1: '2022-03-30T07:20:50Z' + datetime_field_2: + eq: '2022-03-30T07:20:50Z' + datetime_field_3: + lt: '2022-03-30T07:20:50Z' + datetime_field_4: + lte: '2022-03-30T07:20:50Z' + datetime_field_5: + gt: '2022-03-30T07:20:50Z' + datetime_field_6: + gte: '2022-03-30T07:20:50Z' Labels: title: Labels type: object @@ -1278,14 +1520,6 @@ components: example: kong:trace:1234567890 detail: example: Not found - parameters: - CursorPageQuery: - name: page - description: Determines which page of the collection to retrieve. - required: false - in: query - schema: - $ref: '#/components/schemas/CursorPageParameters' responses: BadRequest: description: Bad Request From 63cb67cb8c1c72251e079835db3db99aa9195f22 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:18:23 +0100 Subject: [PATCH 05/18] feat(customer): update customer list adapter filters --- e2e/customer_test.go | 615 ++++++++++++++++++ openmeter/billing/service/customeroverride.go | 9 +- openmeter/customer/adapter/customer.go | 23 +- openmeter/customer/customer.go | 43 +- openmeter/customer/httpdriver/customer.go | 15 +- openmeter/meter/httphandler/mapping.go | 2 +- openmeter/meterevent/adapter/event.go | 3 +- pkg/filter/filter.go | 10 +- pkg/filter/filter_test.go | 6 +- test/customer/customer.go | 9 +- 10 files changed, 695 insertions(+), 40 deletions(-) create mode 100644 e2e/customer_test.go diff --git a/e2e/customer_test.go b/e2e/customer_test.go new file mode 100644 index 0000000000..23a3081dbd --- /dev/null +++ b/e2e/customer_test.go @@ -0,0 +1,615 @@ +package e2e + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/client/go" +) + +func TestCustomerList(t *testing.T) { + client := initClient(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Create multiple customers with varying properties to test different filter scenarios + var customers []*api.Customer + + // Customer 1: Tech startup with subscription + { + resp, err := client.UpsertSubjectWithResponse(ctx, api.UpsertSubjectJSONRequestBody{ + api.SubjectUpsert{Key: "subject_tech_startup"}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + customerResp, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ + Key: lo.ToPtr("tech-startup-001"), + Name: "Tech Startup Inc", + Currency: lo.ToPtr(api.CurrencyCode("USD")), + Description: lo.ToPtr("A technology startup company"), + PrimaryEmail: lo.ToPtr("contact@techstartup.com"), + BillingAddress: &api.Address{ + City: lo.ToPtr("San Francisco"), + Country: lo.ToPtr("US"), + Line1: lo.ToPtr("123 Market St"), + State: lo.ToPtr("CA"), + PostalCode: lo.ToPtr("94102"), + }, + UsageAttribution: api.CustomerUsageAttribution{ + SubjectKeys: []string{"subject_tech_startup"}, + }, + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, customerResp.StatusCode(), "body: %s", customerResp.Body) + customers = append(customers, customerResp.JSON201) + } + + // Customer 2: E-commerce company + { + resp, err := client.UpsertSubjectWithResponse(ctx, api.UpsertSubjectJSONRequestBody{ + api.SubjectUpsert{Key: "subject_ecommerce"}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + customerResp, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ + Key: lo.ToPtr("ecommerce-corp-002"), + Name: "E-Commerce Corp", + Currency: lo.ToPtr(api.CurrencyCode("EUR")), + Description: lo.ToPtr("An e-commerce platform"), + PrimaryEmail: lo.ToPtr("admin@ecommerce-corp.com"), + BillingAddress: &api.Address{ + City: lo.ToPtr("Berlin"), + Country: lo.ToPtr("DE"), + Line1: lo.ToPtr("456 Commerce Ave"), + PostalCode: lo.ToPtr("10115"), + }, + UsageAttribution: api.CustomerUsageAttribution{ + SubjectKeys: []string{"subject_ecommerce"}, + }, + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, customerResp.StatusCode(), "body: %s", customerResp.Body) + customers = append(customers, customerResp.JSON201) + } + + // Customer 3: Enterprise client with multiple subjects + { + resp, err := client.UpsertSubjectWithResponse(ctx, api.UpsertSubjectJSONRequestBody{ + api.SubjectUpsert{Key: "subject_enterprise_main"}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + resp, err = client.UpsertSubjectWithResponse(ctx, api.UpsertSubjectJSONRequestBody{ + api.SubjectUpsert{Key: "subject_enterprise_backup"}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + customerResp, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ + Key: lo.ToPtr("enterprise-client-003"), + Name: "Enterprise Solutions Ltd", + Currency: lo.ToPtr(api.CurrencyCode("GBP")), + Description: lo.ToPtr("Large enterprise client"), + PrimaryEmail: lo.ToPtr("billing@enterprise.co.uk"), + BillingAddress: &api.Address{ + City: lo.ToPtr("London"), + Country: lo.ToPtr("GB"), + Line1: lo.ToPtr("789 Business Rd"), + PostalCode: lo.ToPtr("SW1A 1AA"), + }, + UsageAttribution: api.CustomerUsageAttribution{ + SubjectKeys: []string{"subject_enterprise_main", "subject_enterprise_backup"}, + }, + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, customerResp.StatusCode(), "body: %s", customerResp.Body) + customers = append(customers, customerResp.JSON201) + } + + // Customer 4: Tech company (for name filtering) + { + resp, err := client.UpsertSubjectWithResponse(ctx, api.UpsertSubjectJSONRequestBody{ + api.SubjectUpsert{Key: "subject_tech_company"}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + customerResp, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ + Key: lo.ToPtr("tech-innovations-004"), + Name: "Tech Innovations LLC", + Currency: lo.ToPtr(api.CurrencyCode("USD")), + Description: lo.ToPtr("Technology innovations company"), + PrimaryEmail: lo.ToPtr("info@techinnovations.com"), + BillingAddress: &api.Address{ + City: lo.ToPtr("Austin"), + Country: lo.ToPtr("US"), + Line1: lo.ToPtr("321 Innovation Blvd"), + State: lo.ToPtr("TX"), + PostalCode: lo.ToPtr("78701"), + }, + UsageAttribution: api.CustomerUsageAttribution{ + SubjectKeys: []string{"subject_tech_company"}, + }, + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, customerResp.StatusCode(), "body: %s", customerResp.Body) + customers = append(customers, customerResp.JSON201) + } + + // Customer 5: Small business (for email domain filtering) + { + resp, err := client.UpsertSubjectWithResponse(ctx, api.UpsertSubjectJSONRequestBody{ + api.SubjectUpsert{Key: "subject_small_business"}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + customerResp, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ + Key: lo.ToPtr("small-biz-005"), + Name: "Small Business Co", + Currency: lo.ToPtr(api.CurrencyCode("USD")), + PrimaryEmail: lo.ToPtr("owner@smallbiz.com"), + UsageAttribution: api.CustomerUsageAttribution{ + SubjectKeys: []string{"subject_small_business"}, + }, + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, customerResp.StatusCode(), "body: %s", customerResp.Body) + customers = append(customers, customerResp.JSON201) + } + + t.Run("Should list all customers with default pagination", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + // Should have at least our 5 customers + assert.GreaterOrEqual(t, len(resp.JSON200.Items), 5) + assert.GreaterOrEqual(t, resp.JSON200.TotalCount, 5) + }) + + t.Run("Should filter customers by key - exact match", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Key: lo.ToPtr("tech-startup-001"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + assert.Equal(t, 1, resp.JSON200.TotalCount) + require.Len(t, resp.JSON200.Items, 1) + assert.Equal(t, "tech-startup-001", *resp.JSON200.Items[0].Key) + assert.Equal(t, "Tech Startup Inc", resp.JSON200.Items[0].Name) + }) + + t.Run("Should filter customers by key - partial match", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Key: lo.ToPtr("tech"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + // Should match "tech-startup-001" and "tech-innovations-004" + assert.GreaterOrEqual(t, resp.JSON200.TotalCount, 2) + for _, customer := range resp.JSON200.Items { + assert.Contains(t, *customer.Key, "tech") + } + }) + + t.Run("Should filter customers by name - partial match", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Name: lo.ToPtr("Tech"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + // Should match "Tech Startup Inc" and "Tech Innovations LLC" + assert.GreaterOrEqual(t, resp.JSON200.TotalCount, 2) + for _, customer := range resp.JSON200.Items { + assert.Contains(t, customer.Name, "Tech") + } + }) + + t.Run("Should filter customers by name - case insensitive", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Name: lo.ToPtr("enterprise"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + assert.GreaterOrEqual(t, resp.JSON200.TotalCount, 1) + found := false + for _, customer := range resp.JSON200.Items { + if customer.Name == "Enterprise Solutions Ltd" { + found = true + break + } + } + assert.True(t, found, "Should find Enterprise Solutions Ltd") + }) + + t.Run("Should filter customers by primary email - exact domain", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + PrimaryEmail: lo.ToPtr("techstartup.com"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + assert.GreaterOrEqual(t, resp.JSON200.TotalCount, 1) + found := false + for _, customer := range resp.JSON200.Items { + if customer.PrimaryEmail != nil && *customer.PrimaryEmail == "contact@techstartup.com" { + found = true + break + } + } + assert.True(t, found, "Should find customer with techstartup.com email") + }) + + t.Run("Should filter customers by primary email - partial match", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + PrimaryEmail: lo.ToPtr("ecommerce"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + assert.GreaterOrEqual(t, resp.JSON200.TotalCount, 1) + found := false + for _, customer := range resp.JSON200.Items { + if customer.PrimaryEmail != nil && *customer.PrimaryEmail == "admin@ecommerce-corp.com" { + found = true + break + } + } + assert.True(t, found, "Should find E-Commerce Corp") + }) + + t.Run("Should filter customers by subject - single subject", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Subject: lo.ToPtr("subject_tech_startup"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + assert.GreaterOrEqual(t, resp.JSON200.TotalCount, 1) + found := false + for _, customer := range resp.JSON200.Items { + if customer.Name == "Tech Startup Inc" { + found = true + assert.Contains(t, customer.UsageAttribution.SubjectKeys, "subject_tech_startup") + break + } + } + assert.True(t, found, "Should find Tech Startup Inc") + }) + + t.Run("Should filter customers by subject - partial match", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Subject: lo.ToPtr("enterprise"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + // Should match "subject_enterprise_main" and "subject_enterprise_backup" + assert.GreaterOrEqual(t, resp.JSON200.TotalCount, 1) + found := false + for _, customer := range resp.JSON200.Items { + if customer.Name == "Enterprise Solutions Ltd" { + found = true + break + } + } + assert.True(t, found, "Should find Enterprise Solutions Ltd") + }) + + t.Run("Should order customers by name ascending", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + OrderBy: lo.ToPtr(api.CustomerOrderByName), + Order: lo.ToPtr(api.SortOrderASC), + Name: lo.ToPtr("Tech"), // Filter to limit results + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + require.GreaterOrEqual(t, len(resp.JSON200.Items), 2) + // Verify ordering + for i := 0; i < len(resp.JSON200.Items)-1; i++ { + assert.LessOrEqual(t, resp.JSON200.Items[i].Name, resp.JSON200.Items[i+1].Name, + "Customers should be ordered by name ascending") + } + }) + + t.Run("Should order customers by name descending", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + OrderBy: lo.ToPtr(api.CustomerOrderByName), + Order: lo.ToPtr(api.SortOrderDESC), + Name: lo.ToPtr("Tech"), // Filter to limit results + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + require.GreaterOrEqual(t, len(resp.JSON200.Items), 2) + // Verify ordering + for i := 0; i < len(resp.JSON200.Items)-1; i++ { + assert.GreaterOrEqual(t, resp.JSON200.Items[i].Name, resp.JSON200.Items[i+1].Name, + "Customers should be ordered by name descending") + } + }) + + t.Run("Should order customers by createdAt", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + OrderBy: lo.ToPtr(api.CustomerOrderByCreatedAt), + Order: lo.ToPtr(api.SortOrderASC), + Key: lo.ToPtr("tech"), // Filter to our test customers + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + // Verify ordering + for i := 1; i < len(resp.JSON200.Items); i++ { + prev := resp.JSON200.Items[i-1].CreatedAt + curr := resp.JSON200.Items[i].CreatedAt + assert.False(t, curr.Before(prev), "createdAt should be in ascending order") + } + }) + + t.Run("Should respect page size parameter", func(t *testing.T) { + pageSize := 2 + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + PageSize: lo.ToPtr(pageSize), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + assert.LessOrEqual(t, len(resp.JSON200.Items), pageSize) + assert.Equal(t, pageSize, resp.JSON200.PageSize) + }) + + t.Run("Should paginate through customers", func(t *testing.T) { + pageSize := 2 + page1Resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Page: lo.ToPtr(1), + PageSize: lo.ToPtr(pageSize), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, page1Resp.StatusCode(), "body: %s", page1Resp.Body) + require.NotNil(t, page1Resp.JSON200) + + page2Resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Page: lo.ToPtr(2), + PageSize: lo.ToPtr(pageSize), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, page2Resp.StatusCode(), "body: %s", page2Resp.Body) + require.NotNil(t, page2Resp.JSON200) + + // Pages should have same total count + assert.Equal(t, page1Resp.JSON200.TotalCount, page2Resp.JSON200.TotalCount) + + // Items should not overlap across pages + page1IDs := lo.Map(page1Resp.JSON200.Items, func(c api.Customer, _ int) string { return c.Id }) + page2IDs := lo.Map(page2Resp.JSON200.Items, func(c api.Customer, _ int) string { return c.Id }) + overlap := lo.Intersect(page1IDs, page2IDs) + assert.Len(t, overlap, 0, "items should not repeat across pages") + }) + + t.Run("Should combine multiple filters", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Name: lo.ToPtr("Tech"), + Subject: lo.ToPtr("tech_startup"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + // Should only match "Tech Startup Inc" with subject_tech_startup + found := false + for _, customer := range resp.JSON200.Items { + if customer.Name == "Tech Startup Inc" { + found = true + assert.Contains(t, customer.UsageAttribution.SubjectKeys, "subject_tech_startup") + } + } + assert.True(t, found, "Should find Tech Startup Inc with combined filters") + }) + + t.Run("Should return empty list for non-existent filter", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Key: lo.ToPtr("non-existent-customer-key-12345"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + assert.Equal(t, 0, resp.JSON200.TotalCount) + assert.Len(t, resp.JSON200.Items, 0) + }) + + // Test deleted customers + t.Run("Should filter deleted customers", func(t *testing.T) { + // Create a customer to delete + resp, err := client.UpsertSubjectWithResponse(ctx, api.UpsertSubjectJSONRequestBody{ + api.SubjectUpsert{Key: "subject_to_delete"}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + createResp, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ + Key: lo.ToPtr("customer-to-delete"), + Name: "Customer To Delete", + Currency: lo.ToPtr(api.CurrencyCode("USD")), + PrimaryEmail: lo.ToPtr("delete@test.com"), + UsageAttribution: api.CustomerUsageAttribution{ + SubjectKeys: []string{"subject_to_delete"}, + }, + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, createResp.StatusCode(), "body: %s", createResp.Body) + customerToDelete := createResp.JSON201 + + // Delete the customer + deleteResp, err := client.DeleteCustomerWithResponse(ctx, customerToDelete.Id) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, deleteResp.StatusCode(), "body: %s", deleteResp.Body) + + // List without includeDeleted should not show the deleted customer + listResp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Key: lo.ToPtr("customer-to-delete"), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, listResp.StatusCode(), "body: %s", listResp.Body) + require.NotNil(t, listResp.JSON200) + assert.Equal(t, 0, listResp.JSON200.TotalCount, "Deleted customer should not appear by default") + + // List with includeDeleted should show the deleted customer + listWithDeletedResp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Key: lo.ToPtr("customer-to-delete"), + IncludeDeleted: lo.ToPtr(true), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, listWithDeletedResp.StatusCode(), "body: %s", listWithDeletedResp.Body) + require.NotNil(t, listWithDeletedResp.JSON200) + assert.Equal(t, 1, listWithDeletedResp.JSON200.TotalCount, "Deleted customer should appear with includeDeleted=true") + require.Len(t, listWithDeletedResp.JSON200.Items, 1) + assert.NotNil(t, listWithDeletedResp.JSON200.Items[0].DeletedAt, "Customer should have deletedAt timestamp") + }) + + // Test plan key filter (requires creating a plan and subscription) + t.Run("Should filter customers by plan key", func(t *testing.T) { + featureKey := "customer_list_test_feature" + planKey := "customer_list_test_plan" + rateCardKey := "test_rate_card_customer_list" + + // Create a simple feature for the plan + featureResp, err := client.CreateFeatureWithResponse(ctx, api.CreateFeatureJSONRequestBody{ + Key: featureKey, + Name: "Customer List Test Feature", + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, featureResp.StatusCode(), "body: %s", featureResp.Body) + + // Cleanup: Delete the feature after the test + t.Cleanup(func() { + _, _ = client.DeleteFeatureWithResponse(ctx, featureResp.JSON201.Id) + }) + + // Create a simple plan + p1RC1 := api.RateCard{} + err = p1RC1.FromRateCardFlatFee(api.RateCardFlatFee{ + Name: "Test Rate Card", + Key: rateCardKey, + Price: &api.FlatPriceWithPaymentTerm{ + Amount: "100", + PaymentTerm: lo.ToPtr(api.PricePaymentTerm("in_advance")), + Type: api.FlatPriceWithPaymentTermType("flat"), + }, + BillingCadence: lo.ToPtr("P1M"), + Type: api.RateCardFlatFeeType("flat"), + }) + require.NoError(t, err) + + planResp, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ + Key: planKey, + Name: "Customer List Test Plan", + Currency: api.CurrencyCode("USD"), + BillingCadence: "P1M", + Phases: []api.PlanPhase{ + { + Name: "Test Phase", + Key: "test_phase", + RateCards: []api.RateCard{p1RC1}, + }, + }, + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, planResp.StatusCode(), "body: %s", planResp.Body) + plan := planResp.JSON201 + + // Cleanup: Archive the plan after the test + t.Cleanup(func() { + _, _ = client.ArchivePlanWithResponse(ctx, plan.Id) + }) + + // Publish the plan + publishResp, err := client.PublishPlanWithResponse(ctx, plan.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, publishResp.StatusCode(), "body: %s", publishResp.Body) + + // Create a subscription for the first customer + ct := &api.SubscriptionTiming{} + require.NoError(t, ct.FromSubscriptionTimingEnum(api.SubscriptionTimingEnumImmediate)) + + subCreate := api.SubscriptionCreate{} + err = subCreate.FromPlanSubscriptionCreate(api.PlanSubscriptionCreate{ + Timing: ct, + CustomerId: &customers[0].Id, + Plan: api.PlanReferenceInput{ + Key: plan.Key, + Version: lo.ToPtr(1), + }, + }) + require.NoError(t, err) + + subResp, err := client.CreateSubscriptionWithResponse(ctx, subCreate) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, subResp.StatusCode(), "body: %s", subResp.Body) + + // Now filter customers by plan key + listResp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + PlanKey: lo.ToPtr(planKey), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, listResp.StatusCode(), "body: %s", listResp.Body) + require.NotNil(t, listResp.JSON200) + + // Should find at least the customer with the subscription + assert.GreaterOrEqual(t, listResp.JSON200.TotalCount, 1) + found := false + for _, customer := range listResp.JSON200.Items { + if customer.Id == customers[0].Id { + found = true + break + } + } + assert.True(t, found, fmt.Sprintf("Should find customer %s with plan subscription", customers[0].Name)) + }) + + t.Run("Should expand subscriptions when requested", func(t *testing.T) { + resp, err := client.ListCustomersWithResponse(ctx, &api.ListCustomersParams{ + Key: lo.ToPtr("tech-startup-001"), + Expand: &api.QueryCustomerListExpand{api.CustomerExpandSubscriptions}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", resp.Body) + require.NotNil(t, resp.JSON200) + + require.Len(t, resp.JSON200.Items, 1) + customer := resp.JSON200.Items[0] + + // Customer should have subscriptions field populated + assert.NotNil(t, customer.Subscriptions, "Subscriptions should be populated when expand is requested") + assert.Greater(t, len(lo.FromPtr(customer.Subscriptions)), 0, "Customer should have at least one subscription expanded") + }) +} diff --git a/openmeter/billing/service/customeroverride.go b/openmeter/billing/service/customeroverride.go index d0b6afb532..0375faa8e5 100644 --- a/openmeter/billing/service/customeroverride.go +++ b/openmeter/billing/service/customeroverride.go @@ -9,6 +9,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/app" "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/framework/transaction" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" @@ -212,9 +213,11 @@ func (s *Service) ListCustomerOverrides(ctx context.Context, input billing.ListC if input.Expand.Customer { customers, err := s.customerService.ListCustomers(ctx, customer.ListCustomersInput{ Namespace: input.Namespace, - CustomerIDs: lo.Map(res.Items, func(override billing.CustomerOverrideWithCustomerID, _ int) string { - return override.CustomerID.ID - }), + CustomerIDs: &filter.FilterString{ + In: lo.ToPtr(lo.Map(res.Items, func(override billing.CustomerOverrideWithCustomerID, _ int) string { + return override.CustomerID.ID + })), + }, }) if err != nil { return billing.ListCustomerOverridesResult{}, err diff --git a/openmeter/customer/adapter/customer.go b/openmeter/customer/adapter/customer.go index d0e3f5a632..8dfba83dfe 100644 --- a/openmeter/customer/adapter/customer.go +++ b/openmeter/customer/adapter/customer.go @@ -49,22 +49,23 @@ func (a *adapter) ListCustomers(ctx context.Context, input customer.ListCustomer )) } - // Filters - if input.Key != nil { - query = query.Where(customerdb.KeyContainsFold(*input.Key)) + // Filters using FilterString predicates + if input.Key != nil && !input.Key.IsEmpty() { + query = query.Where(predicate.Customer(input.Key.Where(customerdb.FieldKey))) } - if input.Name != nil { - query = query.Where(customerdb.NameContainsFold(*input.Name)) + if input.Name != nil && !input.Name.IsEmpty() { + query = query.Where(predicate.Customer(input.Name.Where(customerdb.FieldName))) } - if input.PrimaryEmail != nil { - query = query.Where(customerdb.PrimaryEmailContainsFold(*input.PrimaryEmail)) + if input.PrimaryEmail != nil && !input.PrimaryEmail.IsEmpty() { + query = query.Where(predicate.Customer(input.PrimaryEmail.Where(customerdb.FieldPrimaryEmail))) } - if input.Subject != nil { + if input.Subject != nil && !input.Subject.IsEmpty() { + // Subject filter applies to the subjects table query = query.Where(customerdb.HasSubjectsWith( - customersubjectsdb.SubjectKeyContainsFold(*input.Subject), + predicate.CustomerSubjects(input.Subject.Where(customersubjectsdb.FieldSubjectKey)), customersubjectsdb.Or( customersubjectsdb.DeletedAtIsNil(), customersubjectsdb.DeletedAtGTE(now), @@ -76,8 +77,8 @@ func (a *adapter) ListCustomers(ctx context.Context, input customer.ListCustomer applyActiveSubscriptionFilterWithPlanKey(query, now, *input.PlanKey) } - if len(input.CustomerIDs) > 0 { - query = query.Where(customerdb.IDIn(input.CustomerIDs...)) + if input.CustomerIDs != nil && !input.CustomerIDs.IsEmpty() { + query = query.Where(predicate.Customer(input.CustomerIDs.Where(customerdb.FieldID))) } // Order diff --git a/openmeter/customer/customer.go b/openmeter/customer/customer.go index 44bee01c33..fa665c5670 100644 --- a/openmeter/customer/customer.go +++ b/openmeter/customer/customer.go @@ -11,6 +11,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" "github.com/openmeterio/openmeter/pkg/sortx" @@ -268,12 +269,12 @@ type ListCustomersInput struct { Order sortx.Order // Filters - Key *string - Name *string - PrimaryEmail *string - Subject *string + Key *filter.FilterString + Name *filter.FilterString + PrimaryEmail *filter.FilterString + Subject *filter.FilterString PlanKey *string - CustomerIDs []string + CustomerIDs *filter.FilterString // Expand Expands Expands @@ -288,6 +289,38 @@ func (i ListCustomersInput) Validate() error { return models.NewGenericValidationError(err) } + // Validate filters + // Only non-nested filters are supported for now + if i.Key != nil { + if err := i.Key.ValidateWithComplexity(1); err != nil { + return models.NewGenericValidationError(fmt.Errorf("invalid key filter: %w", err)) + } + } + + if i.Name != nil { + if err := i.Name.ValidateWithComplexity(1); err != nil { + return models.NewGenericValidationError(fmt.Errorf("invalid name filter: %w", err)) + } + } + + if i.PrimaryEmail != nil { + if err := i.PrimaryEmail.ValidateWithComplexity(1); err != nil { + return models.NewGenericValidationError(fmt.Errorf("invalid primaryEmail filter: %w", err)) + } + } + + if i.Subject != nil { + if err := i.Subject.ValidateWithComplexity(1); err != nil { + return models.NewGenericValidationError(fmt.Errorf("invalid subject filter: %w", err)) + } + } + + if i.CustomerIDs != nil { + if err := i.CustomerIDs.ValidateWithComplexity(1); err != nil { + return models.NewGenericValidationError(fmt.Errorf("invalid customerIDs filter: %w", err)) + } + } + return nil } diff --git a/openmeter/customer/httpdriver/customer.go b/openmeter/customer/httpdriver/customer.go index a3b9832af7..a83118863b 100644 --- a/openmeter/customer/httpdriver/customer.go +++ b/openmeter/customer/httpdriver/customer.go @@ -15,6 +15,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/subscription" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/defaultx" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" "github.com/openmeterio/openmeter/pkg/models" @@ -51,13 +52,6 @@ func (h *handler) ListCustomers() ListCustomersHandler { OrderBy: defaultx.WithDefault(params.OrderBy, api.CustomerOrderByName), Order: sortx.Order(defaultx.WithDefault(params.Order, api.SortOrderASC)), - // Filters - Key: params.Key, - Name: params.Name, - PrimaryEmail: params.PrimaryEmail, - Subject: params.Subject, - PlanKey: params.PlanKey, - // Modifiers IncludeDeleted: lo.FromPtrOr(params.IncludeDeleted, customer.IncludeDeleted), @@ -68,6 +62,13 @@ func (h *handler) ListCustomers() ListCustomersHandler { }), } + // Filters + req.PlanKey = params.PlanKey + req.Key = lo.Ternary(params.Key != nil, &filter.FilterString{Ilike: params.Key}, nil) + req.Name = lo.Ternary(params.Name != nil, &filter.FilterString{Ilike: params.Name}, nil) + req.PrimaryEmail = lo.Ternary(params.PrimaryEmail != nil, &filter.FilterString{Ilike: params.PrimaryEmail}, nil) + req.Subject = lo.Ternary(params.Subject != nil, &filter.FilterString{Ilike: params.Subject}, nil) + if err := req.Page.Validate(); err != nil { return ListCustomersRequest{}, err } diff --git a/openmeter/meter/httphandler/mapping.go b/openmeter/meter/httphandler/mapping.go index fdc8350b8b..309128db6c 100644 --- a/openmeter/meter/httphandler/mapping.go +++ b/openmeter/meter/httphandler/mapping.go @@ -208,7 +208,7 @@ func (h *handler) getFilterCustomer(ctx context.Context, namespace string, filte // List customers customers, err := h.customerService.ListCustomers(ctx, customer.ListCustomersInput{ Namespace: namespace, - CustomerIDs: filterCustomerIds, + CustomerIDs: &filter.FilterString{In: &filterCustomerIds}, }) if err != nil { return nil, fmt.Errorf("failed to list customers: %w", err) diff --git a/openmeter/meterevent/adapter/event.go b/openmeter/meterevent/adapter/event.go index ffc6866d87..eee7b59817 100644 --- a/openmeter/meterevent/adapter/event.go +++ b/openmeter/meterevent/adapter/event.go @@ -11,6 +11,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/meter" "github.com/openmeterio/openmeter/openmeter/meterevent" "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination/v2" ) @@ -123,7 +124,7 @@ func (a *adapter) ListEventsV2(ctx context.Context, params meterevent.ListEvents func (a *adapter) listCustomers(ctx context.Context, namespace string, customerIDs []string) ([]streaming.Customer, error) { customerList, err := a.customerService.ListCustomers(ctx, customer.ListCustomersInput{ Namespace: namespace, - CustomerIDs: customerIDs, + CustomerIDs: &filter.FilterString{In: &customerIDs}, }) if err != nil { return nil, fmt.Errorf("list customers: %w", err) diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go index d017bb836c..bb87af079b 100644 --- a/pkg/filter/filter.go +++ b/pkg/filter/filter.go @@ -179,12 +179,12 @@ func (f FilterString) SelectWherePredicate(field string) *sql.Predicate { case f.Nlike != nil: return sql.Not(sql.Like(field, *f.Nlike)) case f.Ilike != nil: - // ILike is technically PostgreSQL specific, but ent/sql handles it - // TODO: use ILIKE somehow - return sql.EqualFold(field, *f.Ilike) // or sql.Expr("? ILIKE ?", ...) + // Use ContainsFold for case-insensitive substring matching + // This generates ILIKE with % wildcards automatically + return sql.ContainsFold(field, *f.Ilike) case f.Nilike != nil: - // TODO: use ILIKE somehow - return sql.Not(sql.EqualFold(field, *f.Nilike)) + // Use NOT ContainsFold for negated case-insensitive substring matching + return sql.Not(sql.ContainsFold(field, *f.Nilike)) case f.Gt != nil: return sql.GT(field, *f.Gt) case f.Gte != nil: diff --git a/pkg/filter/filter_test.go b/pkg/filter/filter_test.go index 0916dec95a..07af349791 100644 --- a/pkg/filter/filter_test.go +++ b/pkg/filter/filter_test.go @@ -1999,7 +1999,7 @@ func TestFilterString_SelectWherePredicate(t *testing.T) { { name: "ilike filter", filter: filter.FilterString{ - Ilike: lo.ToPtr("%test%"), + Ilike: lo.ToPtr("test"), }, field: "test_field", wantNil: false, @@ -2009,11 +2009,11 @@ func TestFilterString_SelectWherePredicate(t *testing.T) { { name: "nilike filter", filter: filter.FilterString{ - Nilike: lo.ToPtr("%test%"), + Nilike: lo.ToPtr("test"), }, field: "test_field", wantNil: false, - wantSQL: "\"test_field\" NOT ILIKE $1", + wantSQL: "NOT (\"test_field\" ILIKE $1)", wantArgs: []interface{}{"%test%"}, }, { diff --git a/test/customer/customer.go b/test/customer/customer.go index 3f18ae0f1f..41ddfba7d2 100644 --- a/test/customer/customer.go +++ b/test/customer/customer.go @@ -23,6 +23,7 @@ import ( "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/datetime" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" "github.com/openmeterio/openmeter/pkg/sortx" @@ -552,7 +553,7 @@ func (s *CustomerHandlerTestSuite) TestList(ctx context.Context, t *testing.T) { list, err = service.ListCustomers(ctx, customer.ListCustomersInput{ Namespace: s.namespace, Page: page, - Key: lo.ToPtr("customer-1"), + Key: &filter.FilterString{Ilike: lo.ToPtr("customer-1")}, }) require.NoError(t, err, "Listing customers with key filter must not return error") @@ -563,7 +564,7 @@ func (s *CustomerHandlerTestSuite) TestList(ctx context.Context, t *testing.T) { list, err = service.ListCustomers(ctx, customer.ListCustomersInput{ Namespace: s.namespace, Page: page, - Name: &createCustomer2.Name, + Name: &filter.FilterString{Ilike: lo.ToPtr(createCustomer2.Name)}, }) require.NoError(t, err, "Listing customers with name filter must not return error") @@ -574,7 +575,7 @@ func (s *CustomerHandlerTestSuite) TestList(ctx context.Context, t *testing.T) { list, err = service.ListCustomers(ctx, customer.ListCustomersInput{ Namespace: s.namespace, Page: page, - Name: lo.ToPtr("2"), + Name: &filter.FilterString{Ilike: lo.ToPtr("2")}, }) require.NoError(t, err, "Listing customers with partial name filter must not return error") @@ -585,7 +586,7 @@ func (s *CustomerHandlerTestSuite) TestList(ctx context.Context, t *testing.T) { list, err = service.ListCustomers(ctx, customer.ListCustomersInput{ Namespace: s.namespace, Page: page, - PrimaryEmail: createCustomer2.PrimaryEmail, + PrimaryEmail: &filter.FilterString{Ilike: lo.ToPtr(*createCustomer2.PrimaryEmail)}, }) require.NoError(t, err, "Listing customers with primary email filter must not return error") From ce2bb7fb856e8b0d52250c4f3ad4c4c9ca261c23 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:58:29 +0100 Subject: [PATCH 06/18] fix: linter --- api/spec/src/v3/customers/operations.tsp | 22 ++++++++++++---------- api/spec/src/v3/shared/request.tsp | 6 +++++- api/v3/apierrors/errors.go | 2 +- api/v3/apierrors/options.go | 4 ++-- api/v3/handlers/convert.go | 3 ++- api/v3/handlers/customer.go | 6 +++--- api/v3/response/pagination.go | 3 ++- api/v3/server/server.go | 5 +++-- openmeter/server/server.go | 2 +- 9 files changed, 31 insertions(+), 22 deletions(-) diff --git a/api/spec/src/v3/customers/operations.tsp b/api/spec/src/v3/customers/operations.tsp index 6fa013d964..9867d0b87e 100644 --- a/api/spec/src/v3/customers/operations.tsp +++ b/api/spec/src/v3/customers/operations.tsp @@ -39,17 +39,17 @@ model ListCustomersParams { /** * Filter customers returned in the response. */ - @query(#{style: "deepObject"}) + @query(#{ style: "deepObject" }) filter?: { - id: Common.StringFieldFilter, - key: Common.StringFieldFilter, - name: Common.StringFieldFilter, - `usage_attribution.subject_keys`: Common.StringFieldFilter, - primary_email: Common.StringFieldFilter, - created_at: Common.DateTimeFieldFilter, - updated_at: Common.DateTimeFieldFilter, - deleted_at: Common.DateTimeFieldFilter, - } + id: Common.StringFieldFilter; + key: Common.StringFieldFilter; + name: Common.StringFieldFilter; + `usage_attribution.subject_keys`: Common.StringFieldFilter; + primary_email: Common.StringFieldFilter; + created_at: Common.DateTimeFieldFilter; + updated_at: Common.DateTimeFieldFilter; + deleted_at: Common.DateTimeFieldFilter; + }; } interface CustomersOperations { @@ -89,6 +89,7 @@ interface CustomersOperations { @extension(Shared.InternalExtension, true) upsert( @path customerId: Shared.ULID, + @body customer: Shared.UpsertRequest, ): Shared.UpsertResponse | Common.NotFound | Common.ErrorResponses; @@ -100,6 +101,7 @@ interface CustomersOperations { @extension(Shared.InternalExtension, true) update( @path customerId: Shared.ULID, + @body customer: Shared.UpdateRequest, ): Shared.UpdateResponse | Common.NotFound | Common.ErrorResponses; diff --git a/api/spec/src/v3/shared/request.tsp b/api/spec/src/v3/shared/request.tsp index 91528efad5..207d55867e 100644 --- a/api/spec/src/v3/shared/request.tsp +++ b/api/spec/src/v3/shared/request.tsp @@ -15,4 +15,8 @@ model UpsertRequest is DefaultKeyVisibility; @doc("{name} update request.", T) @friendlyName("Update{name}Request", T) @withVisibility(Lifecycle.Update) -model UpdateRequest is OptionalProperties>>; +model UpdateRequest + is OptionalProperties>>; diff --git a/api/v3/apierrors/errors.go b/api/v3/apierrors/errors.go index c0334a8d0c..7101bc0fb0 100644 --- a/api/v3/apierrors/errors.go +++ b/api/v3/apierrors/errors.go @@ -43,7 +43,7 @@ type BaseAPIError struct { Detail string `json:"detail"` // Used to indicate which fields have invalid values when validated. Both a - // human-readable value (reason) and a type that can be used for localised + // human-readable value (reason) and a type that can be used for localized // results (rule) are provided. InvalidParameters InvalidParameters `json:"invalid_parameters,omitempty"` diff --git a/api/v3/apierrors/options.go b/api/v3/apierrors/options.go index b062213131..5b19a831b2 100644 --- a/api/v3/apierrors/options.go +++ b/api/v3/apierrors/options.go @@ -7,12 +7,12 @@ type configOption func(*config) var ContextCanceledError = &BaseAPIError{ Status: 499, Title: "Client Closed Request", - Detail: "context cancelled", + Detail: "context canceled", } // WithRecastContextCancelled sets the BaseAPIError that would be recasted into // if an APIError is a context.Canceled. This is to avoid false 500 errors -func WithRecastContextCancelled(e BaseAPIError) configOption { // nolint:gocritic +func WithRecastContextCancelled(e BaseAPIError) configOption { //nolint:gocritic return func(c *config) { c.recastContextCanceled = e } diff --git a/api/v3/handlers/convert.go b/api/v3/handlers/convert.go index 3cd62dd3f5..fb392abb76 100644 --- a/api/v3/handlers/convert.go +++ b/api/v3/handlers/convert.go @@ -5,12 +5,13 @@ package handlers import ( "time" + "github.com/samber/lo" + api "github.com/openmeterio/openmeter/api/v3" "github.com/openmeterio/openmeter/api/v3/response" "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/meter" "github.com/openmeterio/openmeter/pkg/pagination/v2" - "github.com/samber/lo" ) // goverter:variables diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index bb9db7f0bb..ef1bbd04fe 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -5,14 +5,14 @@ import ( "fmt" "net/http" + "github.com/samber/lo" + api "github.com/openmeterio/openmeter/api/v3" - v3 "github.com/openmeterio/openmeter/api/v3" "github.com/openmeterio/openmeter/api/v3/request" "github.com/openmeterio/openmeter/api/v3/response" "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" - "github.com/samber/lo" ) type CustomerHandler interface { @@ -42,7 +42,7 @@ func NewCustomerHandler( } type ( - ListCustomersParams = v3.ListCustomersParams + ListCustomersParams = api.ListCustomersParams ListCustomersRequest = customer.ListCustomersInput ListCustomersResponse = response.CursorPaginationResponse[Customer] ListCustomersHandler httptransport.HandlerWithArgs[ListCustomersRequest, ListCustomersResponse, ListCustomersParams] diff --git a/api/v3/response/pagination.go b/api/v3/response/pagination.go index bc42f32a4b..8743a4cdbe 100644 --- a/api/v3/response/pagination.go +++ b/api/v3/response/pagination.go @@ -2,8 +2,9 @@ package response import ( "github.com/oapi-codegen/nullable" - "github.com/openmeterio/openmeter/pkg/pagination/v2" "github.com/samber/lo" + + "github.com/openmeterio/openmeter/pkg/pagination/v2" ) // CursorMeta Pagination metadata. diff --git a/api/v3/server/server.go b/api/v3/server/server.go index ce6ede50dc..424491a8fe 100644 --- a/api/v3/server/server.go +++ b/api/v3/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/getkin/kin-openapi/openapi3filter" "github.com/go-chi/chi/v5" oapimiddleware "github.com/oapi-codegen/nethttp-middleware" + api "github.com/openmeterio/openmeter/api/v3" "github.com/openmeterio/openmeter/api/v3/apierrors" "github.com/openmeterio/openmeter/api/v3/handlers" @@ -95,11 +96,11 @@ func (s *Server) RegisterRoutes(r chi.Router) { r.Route(s.BaseURL, func(r chi.Router) { // Serve the OpenAPI spec r.Get("/swagger.json", func(w http.ResponseWriter, r *http.Request) { - render.RenderJSON(w, s.swagger) + _ = render.RenderJSON(w, s.swagger) }) r.Get("/swagger.yaml", func(w http.ResponseWriter, r *http.Request) { - render.RenderYAML(w, s.swagger) + _ = render.RenderYAML(w, s.swagger) }) _ = api.HandlerWithOptions(s, api.ChiServerOptions{ diff --git a/openmeter/server/server.go b/openmeter/server/server.go index 9b0b3f7f64..c9e4ab5bdb 100644 --- a/openmeter/server/server.go +++ b/openmeter/server/server.go @@ -12,8 +12,8 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/go-chi/render" - oapimiddleware "github.com/oapi-codegen/nethttp-middleware" + "github.com/openmeterio/openmeter/api" v3server "github.com/openmeterio/openmeter/api/v3/server" "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" From 9235c96aaed77bd4b2873b94d170b704a597f3a1 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:53:39 +0100 Subject: [PATCH 07/18] feat: query parsing --- api/v3/request/query.go | 1849 ++++++++++++++++++++++++++++++++++ api/v3/request/query_test.go | 999 ++++++++++++++++++ 2 files changed, 2848 insertions(+) create mode 100644 api/v3/request/query.go create mode 100644 api/v3/request/query_test.go diff --git a/api/v3/request/query.go b/api/v3/request/query.go new file mode 100644 index 0000000000..d0bdb07814 --- /dev/null +++ b/api/v3/request/query.go @@ -0,0 +1,1849 @@ +package request + +import ( + "context" + "errors" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/apierrors/invalidparameterrules" +) + +// ParseOptions configures how query strings are parsed. +// These options control various aspects of the parsing process, +// including array handling, depth limits, and character encoding. +type ParseOptions struct { + // AllowDots enables parsing of keys with dots (e.g., "a.b.c=value") + // as nested objects instead of treating them as literal keys. + AllowDots bool + + // AllowEmptyArrays allows arrays with no values to be parsed + // as empty arrays instead of being omitted. + AllowEmptyArrays bool + + // AllowPrototypes enables setting properties on Object.prototype + // (mainly for JavaScript compatibility). + AllowPrototypes bool + + // AllowSparse enables sparse arrays where some indices may be missing. + AllowSparse bool + + // ArrayLimit sets the maximum number of array elements to parse. + // Default is 20. Set to 0 for unlimited. + ArrayLimit int + + // Charset specifies the character encoding to use. + // Default is "utf-8". + Charset string + + // CharsetSentinel enables detection of charset parameter in query string. + CharsetSentinel bool + + // Comma enables parsing of comma-separated values within a single parameter. + Comma bool + + // DecodeDotInKeys enables decoding of URL-encoded dots in keys. + DecodeDotInKeys bool + + // Decoder is a custom function for decoding parameter values. + // If nil, url.QueryUnescape is used. + Decoder func(str string, decoder ...interface{}) (string, error) + + // Delimiter specifies the character used to separate parameters. + // Default is "&". + Delimiter string + + // Depth sets the maximum depth for nested objects. + // Default is 5. + Depth int + + // Duplicates specifies how to handle duplicate keys. + // Options: "combine" (default), "first", "last". + Duplicates string + + // IgnoreQueryPrefix ignores leading "?" in query string. + IgnoreQueryPrefix bool + + // InterpretNumericEntities enables interpretation of HTML numeric entities. + InterpretNumericEntities bool + + // ParameterLimit sets the maximum number of parameters to parse. + // Default is 1000. Set to 0 for unlimited. + ParameterLimit int + + // ParseArrays enables parsing of array notation (e.g., "a[]=1&a[]=2"). + ParseArrays bool + + // PlainObjects creates objects without prototypes + // (mainly for JavaScript compatibility). + PlainObjects bool + + // StrictDepth throws an error when depth limit is exceeded + // instead of silently truncating. + StrictDepth bool + + // StrictNullHandling preserves null values instead of converting + // them to empty strings. + StrictNullHandling bool + + // ThrowOnLimitExceeded throws an error when parameter limit is exceeded + // instead of silently truncating. + ThrowOnLimitExceeded bool +} + +// defaultParseOptions returns a ParseOptions struct with default values. +// These defaults provide sensible behavior for most use cases while +// maintaining compatibility with the JavaScript qs library. +func defaultParseOptions() *ParseOptions { + return &ParseOptions{ + AllowDots: false, + AllowEmptyArrays: false, + AllowPrototypes: false, + AllowSparse: false, + ArrayLimit: 20, + Charset: "utf-8", + CharsetSentinel: false, + Comma: false, + DecodeDotInKeys: false, + Decoder: nil, // default decoder will be set in Parse + Delimiter: "&", + Depth: 5, + Duplicates: "combine", + IgnoreQueryPrefix: false, + InterpretNumericEntities: false, + ParameterLimit: 1000, + ParseArrays: false, + PlainObjects: false, + StrictDepth: false, + StrictNullHandling: false, + ThrowOnLimitExceeded: false, + } +} + +type queryParseError struct { + field string + rule string + err error +} + +func (e *queryParseError) Error() string { + if e.field == "" { + return e.err.Error() + } + return fmt.Sprintf("%s: %v", e.field, e.err) +} + +func (e *queryParseError) Unwrap() error { + return e.err +} + +func newQueryAPIError(ctx context.Context, err error, fieldHint string) *apierrors.BaseAPIError { + field := fieldHint + + var qErr *queryParseError + if errors.As(err, &qErr) { + if qErr.field != "" { + field = qErr.field + } + err = qErr.err + if qErr.rule != "" { + return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: field, + Rule: qErr.rule, + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + } + + invalid := apierrors.InvalidParameter{ + Source: apierrors.InvalidParamSourceQuery, + Reason: err.Error(), + Rule: invalidparameterrules.UnknownProperty, + } + + if field != "" { + invalid.Field = field + } + + return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{invalid}) +} + +// Parse parses a query string into a nested data structure. +// +// This function converts URL query strings into Go maps with support for +// nested objects, arrays, and various encoding formats. It's compatible +// with the JavaScript qs library while providing Go-specific features. +// +// # Parameters +// +// - ctx: Context used to build API errors +// - str: The query string to parse (with or without leading "?") +// - opts: Optional ParseOptions to customize parsing behavior +// +// # Return Values +// +// - map[string]interface{}: The parsed data structure +// - *apierrors.BaseAPIError: Any error that occurred during parsing +// +// # Examples +// +// Simple key-value pairs: +// +// result, err := qs.Parse(ctx, "name=John&age=30") +// // Returns: map[string]interface{}{"name": "John", "age": "30"} +// +// Nested objects: +// +// result, err := qs.Parse(ctx, "user[profile][name]=John&user[profile][age]=30") +// // Returns: map[string]interface{}{ +// // "user": map[string]interface{}{ +// // "profile": map[string]interface{}{ +// // "name": "John", +// // "age": "30", +// // }, +// // }, +// // } +// +// Arrays: +// +// result, err := qs.Parse(ctx, "tags[]=golang&tags[]=programming") +// // Returns: map[string]interface{}{"tags": []interface{}{"golang", "programming"}} +// +// With custom options: +// +// result, err := qs.Parse(ctx, "?name=John&age=30", &qs.ParseOptions{ +// IgnoreQueryPrefix: true, +// Delimiter: "&", +// }) +// +// # Error Handling +// +// Parse returns an error if: +// - The parameter limit is exceeded (when ThrowOnLimitExceeded is true) +// - The depth limit is exceeded (when StrictDepth is true) +// - URL decoding fails +// - Custom decoder function returns an error +// +// # Performance +// +// The function is optimized for performance and can handle complex +// nested structures efficiently. For best performance with large +// query strings, consider adjusting ParameterLimit and Depth options. +func Parse(ctx context.Context, str string, opts ...*ParseOptions) (map[string]interface{}, *apierrors.BaseAPIError) { + options := defaultParseOptions() + if len(opts) > 0 && opts[0] != nil { + // Merge user options with defaults + custom := opts[0] + if custom.AllowDots { + options.AllowDots = custom.AllowDots + } + if custom.AllowEmptyArrays { + options.AllowEmptyArrays = custom.AllowEmptyArrays + } + if custom.AllowPrototypes { + options.AllowPrototypes = custom.AllowPrototypes + } + if custom.AllowSparse { + options.AllowSparse = custom.AllowSparse + } + if custom.ArrayLimit != 0 { + options.ArrayLimit = custom.ArrayLimit + } + if custom.Charset != "" { + options.Charset = custom.Charset + } + if custom.CharsetSentinel { + options.CharsetSentinel = custom.CharsetSentinel + } + if custom.Comma { + options.Comma = custom.Comma + } + if custom.DecodeDotInKeys { + options.DecodeDotInKeys = custom.DecodeDotInKeys + } + if custom.Decoder != nil { + options.Decoder = custom.Decoder + } + if custom.Delimiter != "" { + options.Delimiter = custom.Delimiter + } + if custom.Depth != 0 { + options.Depth = custom.Depth + } + if custom.Duplicates != "" { + options.Duplicates = custom.Duplicates + } + if custom.IgnoreQueryPrefix { + options.IgnoreQueryPrefix = custom.IgnoreQueryPrefix + } + if custom.InterpretNumericEntities { + options.InterpretNumericEntities = custom.InterpretNumericEntities + } + if custom.ParameterLimit != 0 { + options.ParameterLimit = custom.ParameterLimit + } + if custom.ParseArrays { + options.ParseArrays = custom.ParseArrays + } + if custom.PlainObjects { + options.PlainObjects = custom.PlainObjects + } + if custom.StrictDepth { + options.StrictDepth = custom.StrictDepth + } + if custom.StrictNullHandling { + options.StrictNullHandling = custom.StrictNullHandling + } + if custom.ThrowOnLimitExceeded { + options.ThrowOnLimitExceeded = custom.ThrowOnLimitExceeded + } + } + + if options.Decoder == nil { + options.Decoder = func(s string, decoder ...interface{}) (string, error) { + return url.QueryUnescape(s) + } + } + + obj := make(map[string]interface{}) + + cleanStr := str + if options.IgnoreQueryPrefix && strings.HasPrefix(cleanStr, "?") { + cleanStr = strings.TrimPrefix(cleanStr, "?") + } + + if cleanStr == "" { + return obj, nil + } + + limit := options.ParameterLimit + if limit == 0 { + limit = 1000 + } + + parts := strings.Split(cleanStr, options.Delimiter) + if options.ThrowOnLimitExceeded && len(parts) > limit { + return nil, newQueryAPIError(ctx, fmt.Errorf("parameter limit exceeded. Only %d parameters allowed", limit), "") + } + + if limit > 0 && len(parts) > limit { + parts = parts[:limit] + } + + for _, part := range parts { + // Skip truly empty parts (from consecutive delimiters) + if part == "" { + continue + } + + var key string + var val interface{} + var err error + + // Find the correct = separator, ignoring those inside brackets + pos := findKeyValueSeparator(part) + + if pos == -1 { + // No equals sign - treat as key with null/empty value + // Don't decode the key yet - parseKeys will handle it + key = part + if options.StrictNullHandling { + val = nil + } else { + val = "" + } + } else { + // Has equals sign - split into key and value + key = part[:pos] + valuePart := part[pos+1:] + + // Decode only the value part + val, err = options.Decoder(valuePart) + if err != nil { + return nil, newQueryAPIError(ctx, err, getCleanKey(key)) + } + } + + if err := parseKeys(key, val, options, obj); err != nil { + return nil, newQueryAPIError(ctx, err, getCleanKey(key)) + } + } + + return obj, nil +} + +// findKeyValueSeparator finds the position of the = that separates key from value, +// ignoring = characters that appear inside brackets +func findKeyValueSeparator(part string) int { + bracketLevel := 0 + for i, ch := range part { + switch ch { + case '[': + bracketLevel++ + case ']': + bracketLevel-- + case '=': + if bracketLevel == 0 { + return i + } + } + } + return -1 +} + +func parseKeys(key string, val interface{}, options *ParseOptions, obj map[string]interface{}) error { + // Handle empty keys - this is allowed in qs + if key == "" { + if existing, ok := obj[""]; ok { + obj[""] = merge(existing, val) + } else { + obj[""] = val + } + return nil + } + + if options.AllowDots { + key = regexp.MustCompile(`\.([^.[]+)`).ReplaceAllString(key, "[$1]") + } + + keys := []string{} + + // Split key into parent and brackets + brackets := regexp.MustCompile(`(\[[^[\]]*\])`) + + segment := brackets.FindStringIndex(key) + parent := key + if segment != nil { + parent = key[:segment[0]] + } + + // Decode and add parent key + if parent != "" { + decodedParent, err := options.Decoder(parent) + if err != nil { + return &queryParseError{ + field: parent, + rule: invalidparameterrules.UnknownProperty, + err: err, + } + } + keys = append(keys, decodedParent) + } else { + keys = append(keys, parent) + } + + // Extract and decode all bracketed keys + matches := brackets.FindAllString(key[len(parent):], -1) + for _, match := range matches { + inner := strings.TrimSuffix(strings.TrimPrefix(match, "["), "]") + // Decode the inner part + decodedInner, err := options.Decoder(inner) + if err != nil { + return &queryParseError{ + field: inner, + rule: invalidparameterrules.UnknownProperty, + err: err, + } + } + keys = append(keys, decodedInner) + } + + return parseObject(keys, val, options, obj) +} + +func merge(a, b interface{}) interface{} { + if a == nil { + return b + } + + aMap, aIsMap := a.(map[string]interface{}) + bMap, bIsMap := b.(map[string]interface{}) + if aIsMap && bIsMap { + for k, v := range bMap { + if av, ok := aMap[k]; ok { + aMap[k] = merge(av, v) + } else { + aMap[k] = v + } + } + return aMap + } + + aSlice, aIsSlice := a.([]interface{}) + bSlice, bIsSlice := b.([]interface{}) + if aIsSlice && bIsSlice { + return append(aSlice, bSlice...) + } + + if aIsSlice && bIsMap { + // Special case: merge array with map containing numeric indices + if canConvertToArray(bMap) { + // Create a combined array + result := make([]interface{}, len(aSlice)) + copy(result, aSlice) + + // Find the maximum index needed + maxIndex := len(result) - 1 + for k := range bMap { + if k != "" && len(k) == 1 && k >= "0" && k <= "9" { + idx := int(k[0] - '0') + if idx > maxIndex { + maxIndex = idx + } + } + } + + // Extend array if needed + if maxIndex >= len(result) { + newResult := make([]interface{}, maxIndex+1) + copy(newResult, result) + result = newResult + } + + // Add values from map to array + for k, v := range bMap { + if k != "" && len(k) == 1 && k >= "0" && k <= "9" { + idx := int(k[0] - '0') + if idx < len(result) { + result[idx] = v + } + } + } + + return result + } + return append(aSlice, b) + } + + if aIsSlice { + return append(aSlice, b) + } + + if bIsSlice { + return append([]interface{}{a}, bSlice...) + } + + // Handle merging map with indexed values into array + if aIsMap { + if bIsSlice { + // Convert map to array if we're merging with an array + arr := convertMapToArray(aMap) + return append(arr, bSlice...) + } + // If b is not an array, try to merge as objects or convert to array + if canConvertToArray(aMap) { + arr := convertMapToArray(aMap) + return append(arr, b) + } + } + + if bIsMap && canConvertToArray(bMap) && aIsSlice { + // This case is now handled above in the aIsSlice && bIsMap block + arr := convertMapToArray(bMap) + return append(aSlice, arr...) + } + + return []interface{}{a, b} +} + +// Helper function to check if a map can be converted to an array (has numeric keys) +func canConvertToArray(m map[string]interface{}) bool { + if len(m) == 0 { + return false + } + + for k := range m { + // Check if key is numeric + if k == "" { + continue + } + // Simple check for numeric keys (0, 1, 2, etc.) + if k < "0" || k > "9" { + return false + } + } + return true +} + +// Helper function to convert a map with numeric keys to an array +func convertMapToArray(m map[string]interface{}) []interface{} { + if len(m) == 0 { + return []interface{}{} + } + + var arr []interface{} + maxIndex := -1 + + // Find the maximum index + for k := range m { + if k == "" { + continue + } + if len(k) == 1 && k >= "0" && k <= "9" { + idx := int(k[0] - '0') + if idx > maxIndex { + maxIndex = idx + } + } + } + + if maxIndex >= 0 { + arr = make([]interface{}, maxIndex+1) + for k, v := range m { + if k == "" { + continue + } + if len(k) == 1 && k >= "0" && k <= "9" { + idx := int(k[0] - '0') + arr[idx] = v + } + } + } + + return arr +} + +func parseObject(chain []string, val interface{}, options *ParseOptions, result map[string]interface{}) error { + if len(chain) == 0 { + return nil + } + + // Handle case with only one key + if len(chain) == 1 { + key := chain[0] + if existing, ok := result[key]; ok { + result[key] = merge(existing, val) + } else { + result[key] = val + } + return nil + } + + // Check depth limit (default is 5) + depth := options.Depth + if depth == 0 { + depth = 5 + } + + // If we exceed the depth limit, combine remaining keys + // depth+1 because we count from 0, so depth 5 allows 6 levels (0,1,2,3,4,5) + if len(chain) > depth+1 { + // Take the first 'depth+1' keys and combine the rest + limitedChain := chain[:depth+1] + remainingKeys := chain[depth+1:] + + // Combine remaining keys into a single key + var combinedKey strings.Builder + for _, key := range remainingKeys { + if key == "" { + combinedKey.WriteString("[]") + } else { + combinedKey.WriteString("[") + combinedKey.WriteString(key) + combinedKey.WriteString("]") + } + } + + // Add the combined key to the limited chain + limitedChain = append(limitedChain, combinedKey.String()) + chain = limitedChain + } + + // Build nested structure from the bottom up + leaf := val + for i := len(chain) - 1; i > 0; i-- { + key := chain[i] + + if key == "" { + // Empty bracket notation creates array + leaf = []interface{}{leaf} + } else { + // Regular key creates object + newObj := make(map[string]interface{}) + newObj[key] = leaf + leaf = newObj + } + } + + // Handle the root key + rootKey := chain[0] + if existing, ok := result[rootKey]; ok { + result[rootKey] = merge(existing, leaf) + } else { + result[rootKey] = leaf + } + + return nil +} + +func getCleanKey(key string) string { + if strings.HasPrefix(key, "[") && strings.HasSuffix(key, "]") { + return key[1 : len(key)-1] + } + return key +} + +// StringifyOptions configures how data structures are converted to query strings. +// These options control various aspects of the stringification process, +// including array formatting, encoding, and output structure. +type StringifyOptions struct { + // AddQueryPrefix adds a leading "?" to the output query string. + AddQueryPrefix bool + + // AllowDots enables dot notation for nested objects (e.g., "a.b.c=value"). + AllowDots bool + + // AllowEmptyArrays includes empty arrays in the output instead of omitting them. + AllowEmptyArrays bool + + // ArrayFormat specifies how arrays are formatted in the query string. + // Options: "indices" (default), "brackets", "repeat". + // - "indices": a[0]=1&a[1]=2 + // - "brackets": a[]=1&a[]=2 + // - "repeat": a=1&a=2 + ArrayFormat string + + // Charset specifies the character encoding to use. + // Default is "utf-8". + Charset string + + // CharsetSentinel includes a charset parameter in the query string + // for better JavaScript compatibility. + CharsetSentinel bool + + // CommaRoundTrip enables comma-separated values within a single parameter + // for better compatibility with specific parsers. + CommaRoundTrip bool + + // Delimiter specifies the character used to separate parameters. + // Default is "&". + Delimiter string + + // Encode enables URL encoding of parameter values. + // Default is true. + Encode bool + + // EncodeDotInKeys enables encoding of dots in parameter keys. + EncodeDotInKeys bool + + // Encoder is a custom function for encoding parameter values. + // If nil, the default URL encoder is used. + Encoder func(str string, defaultEncoder ...interface{}) string + + // EncodeValuesOnly enables encoding only parameter values, + // leaving keys unencoded. + EncodeValuesOnly bool + + // Filter specifies which properties to include in the output. + // Can be a function or a list of allowed keys. + Filter interface{} + + // Format specifies the encoding format. + // Options: "RFC1738", "RFC3986" (default). + Format string + + // Formatter is a custom function for formatting the final output. + Formatter func(string) string + + // Indices is deprecated. Use ArrayFormat instead. + Indices bool + + // SerializeDate is a custom function for serializing time.Time values. + // Default uses RFC3339 format. + SerializeDate func(date time.Time) string + + // SkipNulls omits null/nil values from the output instead of + // including them as empty parameters. + SkipNulls bool + + // StrictNullHandling preserves null values as literal "null" + // instead of converting them to empty strings. + StrictNullHandling bool + + // Sort is a custom function for sorting parameter keys. + // If nil, parameters appear in their natural order. + Sort func(a, b string) bool +} + +var arrayPrefixGenerators = map[string]func(prefix string, key ...string) string{ + "brackets": func(prefix string, key ...string) string { + return prefix + "[]" + }, + "indices": func(prefix string, key ...string) string { + if len(key) > 0 { + return prefix + "[" + key[0] + "]" + } + return prefix + "[]" + }, + "repeat": func(prefix string, key ...string) string { + return prefix + }, +} + +func defaultStringifyOptions() *StringifyOptions { + return &StringifyOptions{ + AddQueryPrefix: false, + AllowDots: false, + AllowEmptyArrays: false, + ArrayFormat: "indices", + Charset: "utf-8", + CharsetSentinel: false, + CommaRoundTrip: false, + Delimiter: "&", + Encode: true, + EncodeDotInKeys: false, + Encoder: nil, + EncodeValuesOnly: false, + Filter: nil, + Format: "RFC3986", + Formatter: nil, + Indices: false, + SerializeDate: func(date time.Time) string { + return date.Format(time.RFC3339) + }, + SkipNulls: false, + StrictNullHandling: false, + Sort: nil, + } +} + +// Stringify converts a data structure into a query string. +// +// This function takes Go data structures (maps, structs, slices) and converts +// them into URL query strings with support for nested objects, arrays, and +// various formatting options. It's compatible with the JavaScript qs library. +// +// # Parameters +// +// - obj: The data structure to convert (map, struct, slice, or primitive value) +// - opts: Optional StringifyOptions to customize output format +// +// # Return Values +// +// - string: The generated query string +// - error: Any error that occurred during stringification +// +// # Examples +// +// Simple map: +// +// data := map[string]interface{}{ +// "name": "John", +// "age": 30, +// } +// result, err := qs.Stringify(data) +// // Returns: "age=30&name=John" +// +// Nested objects: +// +// data := map[string]interface{}{ +// "user": map[string]interface{}{ +// "profile": map[string]interface{}{ +// "name": "John", +// "age": 30, +// }, +// }, +// } +// result, err := qs.Stringify(data) +// // Returns: "user[profile][age]=30&user[profile][name]=John" +// +// Arrays with different formats: +// +// data := map[string]interface{}{ +// "items": []interface{}{"a", "b", "c"}, +// } +// +// // Default (indices) +// result1, err := qs.Stringify(data) +// // Returns: "items[0]=a&items[1]=b&items[2]=c" +// +// // Brackets format +// result2, err := qs.Stringify(data, &qs.StringifyOptions{ +// ArrayFormat: "brackets", +// }) +// // Returns: "items[]=a&items[]=b&items[]=c" +// +// // Repeat format +// result3, err := qs.Stringify(data, &qs.StringifyOptions{ +// ArrayFormat: "repeat", +// }) +// // Returns: "items=a&items=b&items=c" +// +// With query prefix: +// +// result, err := qs.Stringify(data, &qs.StringifyOptions{ +// AddQueryPrefix: true, +// }) +// // Returns: "?items[0]=a&items[1]=b&items[2]=c" +// +// # Supported Data Types +// +// - Maps: map[string]interface{}, map[string]string, etc. +// - Structs: with or without query tags +// - Slices and arrays: []interface{}, []string, []int, etc. +// - Primitive types: string, int, float, bool +// - Pointers: automatically dereferenced +// - time.Time: serialized using SerializeDate function +// +// # Error Handling +// +// Stringify returns an error if: +// - Custom encoder function returns an error +// - Unsupported data type is encountered +// - Reflection operations fail +// +// # Performance +// +// The function is optimized for performance and can handle large +// data structures efficiently. Consider using appropriate StringifyOptions +// for best performance with your specific use case. +func Stringify(obj interface{}, opts ...*StringifyOptions) (string, error) { + options := defaultStringifyOptions() + if len(opts) > 0 && opts[0] != nil { + // Merge custom options with defaults + custom := opts[0] + if custom.AddQueryPrefix { + options.AddQueryPrefix = custom.AddQueryPrefix + } + if custom.AllowDots { + options.AllowDots = custom.AllowDots + } + if custom.AllowEmptyArrays { + options.AllowEmptyArrays = custom.AllowEmptyArrays + } + if custom.ArrayFormat != "" { + options.ArrayFormat = custom.ArrayFormat + } + if custom.Charset != "" { + options.Charset = custom.Charset + } + if custom.CharsetSentinel { + options.CharsetSentinel = custom.CharsetSentinel + } + if custom.CommaRoundTrip { + options.CommaRoundTrip = custom.CommaRoundTrip + } + if custom.Delimiter != "" { + options.Delimiter = custom.Delimiter + } + if custom.Encode { + options.Encode = custom.Encode + } + if custom.EncodeDotInKeys { + options.EncodeDotInKeys = custom.EncodeDotInKeys + } + if custom.Encoder != nil { + options.Encoder = custom.Encoder + } + if custom.EncodeValuesOnly { + options.EncodeValuesOnly = custom.EncodeValuesOnly + } + if custom.Filter != nil { + options.Filter = custom.Filter + } + if custom.Format != "" { + options.Format = custom.Format + } + if custom.Formatter != nil { + options.Formatter = custom.Formatter + } + if custom.Indices { + options.Indices = custom.Indices + } + if custom.SerializeDate != nil { + options.SerializeDate = custom.SerializeDate + } + if custom.SkipNulls { + options.SkipNulls = custom.SkipNulls + } + if custom.StrictNullHandling { + options.StrictNullHandling = custom.StrictNullHandling + } + if custom.Sort != nil { + options.Sort = custom.Sort + } + } + + if options.Encoder == nil { + options.Encoder = func(str string, defaultEncoder ...interface{}) string { + // Use PathEscape instead of QueryEscape to get %20 for spaces instead of + + encoded := url.PathEscape(str) + // PathEscape doesn't encode some characters that QueryEscape does, so we need to handle them + encoded = strings.ReplaceAll(encoded, "=", "%3D") + encoded = strings.ReplaceAll(encoded, "&", "%26") + encoded = strings.ReplaceAll(encoded, "@", "%40") + encoded = strings.ReplaceAll(encoded, "$", "%24") + // Don't encode commas - they are typically not encoded in JavaScript qs + encoded = strings.ReplaceAll(encoded, "%2C", ",") + return encoded + } + } + + if options.Formatter == nil { + options.Formatter = func(str string) string { + return str + } + } + + // Handle falsy values at the top level + if obj == nil { + return "", nil + } + + // Handle falsy primitive values + switch v := obj.(type) { + case bool: + if !v { + return "", nil + } + case int, int8, int16, int32, int64: + if v == 0 { + return "", nil + } + case uint, uint8, uint16, uint32, uint64: + if v == 0 { + return "", nil + } + case float32: + if v == 0.0 { + return "", nil + } + case float64: + if v == 0.0 { + return "", nil + } + case string: + if v == "" { + return "", nil + } + } + + var parts []string + + stringify(&parts, obj, options, "") + + result := strings.Join(parts, options.Delimiter) + + if options.AddQueryPrefix { + result = "?" + result + } + + return result, nil +} + +// stringify is a helper function that recursively converts data structures +// into query string parts. It handles different data types and applies +// the configured formatting options. +func stringify(parts *[]string, obj interface{}, options *StringifyOptions, prefix string) { + if obj == nil { + if options.StrictNullHandling { + *parts = append(*parts, options.Encoder(prefix)) + } else { + *parts = append(*parts, prefix+"=") + } + return + } + + switch v := obj.(type) { + case string, int, int64, int32, int16, int8, uint, uint64, uint32, uint16, uint8, float32, float64, bool: + key := options.Formatter(prefix) + val := options.Formatter(options.Encoder(fmt.Sprintf("%v", v))) + *parts = append(*parts, key+"="+val) + case map[string]interface{}: + for k, val := range v { + newPrefix := k + if prefix != "" { + if options.AllowDots { + newPrefix = prefix + "." + k + } else { + newPrefix = prefix + "[" + k + "]" + } + } + stringify(parts, val, options, newPrefix) + } + case []interface{}: + if gen, ok := arrayPrefixGenerators[options.ArrayFormat]; ok { + for i, val := range v { + newPrefix := gen(prefix, fmt.Sprintf("%d", i)) + stringify(parts, val, options, newPrefix) + } + } else { + // fallback to indices format + for i, val := range v { + newPrefix := prefix + "[" + fmt.Sprintf("%d", i) + "]" + stringify(parts, val, options, newPrefix) + } + } + } +} + +// ParseToStruct parses a query string and fills a struct using query tags +func ParseToStruct(ctx context.Context, str string, dest interface{}, opts ...*ParseOptions) *apierrors.BaseAPIError { + // Parse to map first + result, err := Parse(ctx, str, opts...) + if err != nil { + return err + } + + // Convert map to struct + return MapToStruct(ctx, result, dest) +} + +// MapToStruct converts a map to a struct using query tags +func MapToStruct(ctx context.Context, data map[string]interface{}, dest interface{}) *apierrors.BaseAPIError { + destValue := reflect.ValueOf(dest) + if destValue.Kind() != reflect.Ptr { + return newQueryAPIError(ctx, fmt.Errorf("destination must be a pointer to struct"), "") + } + + destValue = destValue.Elem() + if destValue.Kind() != reflect.Struct { + return newQueryAPIError(ctx, fmt.Errorf("destination must be a pointer to struct"), "") + } + + if err := fillStruct(data, destValue); err != nil { + return newQueryAPIError(ctx, err, "") + } + + return nil +} + +// fillStruct recursively fills struct fields from map data +func fillStruct(data map[string]interface{}, structValue reflect.Value) error { + structType := structValue.Type() + + for i := 0; i < structValue.NumField(); i++ { + field := structValue.Field(i) + fieldType := structType.Field(i) + + // Skip unexported fields + if !field.CanSet() { + continue + } + + // Get query tag + queryTag := fieldType.Tag.Get("query") + if queryTag == "" { + // If no query tag, try to use field name in lowercase + queryTag = strings.ToLower(fieldType.Name) + } + + // Skip fields with query:"-" + if queryTag == "-" { + continue + } + + // Look for the value in data + value, exists := data[queryTag] + if !exists { + continue + } + + if err := setFieldValue(field, value); err != nil { + return &queryParseError{ + field: queryTag, + rule: ruleFromKind(field.Kind()), + err: err, + } + } + } + + return nil +} + +func ruleFromKind(k reflect.Kind) string { + switch k { + case reflect.Bool: + return invalidparameterrules.IsBoolean + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return invalidparameterrules.IsInteger + case reflect.Float32, reflect.Float64: + return invalidparameterrules.IsNumber + case reflect.Map, reflect.Struct: + return invalidparameterrules.IsObject + case reflect.Slice, reflect.Array: + return invalidparameterrules.IsArray + case reflect.String: + return invalidparameterrules.IsString + default: + return invalidparameterrules.UnknownProperty + } +} + +// setFieldValue sets a struct field value from interface{} data +func setFieldValue(field reflect.Value, value interface{}) error { + if value == nil { + return nil + } + + fieldType := field.Type() + valueReflect := reflect.ValueOf(value) + + // Handle pointers + if fieldType.Kind() == reflect.Ptr { + if field.IsNil() { + field.Set(reflect.New(fieldType.Elem())) + } + return setFieldValue(field.Elem(), value) + } + + // Handle different types + switch fieldType.Kind() { + case reflect.String: + if str, ok := value.(string); ok { + field.SetString(str) + } else { + field.SetString(fmt.Sprintf("%v", value)) + } + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if str, ok := value.(string); ok { + if intVal, err := strconv.ParseInt(str, 10, 64); err == nil { + field.SetInt(intVal) + } else { + return fmt.Errorf("cannot convert %q to int", str) + } + } else if intVal, ok := value.(int64); ok { + field.SetInt(intVal) + } else { + return fmt.Errorf("cannot convert %T to int", value) + } + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if str, ok := value.(string); ok { + if uintVal, err := strconv.ParseUint(str, 10, 64); err == nil { + field.SetUint(uintVal) + } else { + return fmt.Errorf("cannot convert %q to uint", str) + } + } else if uintVal, ok := value.(uint64); ok { + field.SetUint(uintVal) + } else { + return fmt.Errorf("cannot convert %T to uint", value) + } + + case reflect.Float32, reflect.Float64: + if str, ok := value.(string); ok { + if floatVal, err := strconv.ParseFloat(str, 64); err == nil { + field.SetFloat(floatVal) + } else { + return fmt.Errorf("cannot convert %q to float", str) + } + } else if floatVal, ok := value.(float64); ok { + field.SetFloat(floatVal) + } else { + return fmt.Errorf("cannot convert %T to float", value) + } + + case reflect.Bool: + if str, ok := value.(string); ok { + if boolVal, err := strconv.ParseBool(str); err == nil { + field.SetBool(boolVal) + } else { + return fmt.Errorf("cannot convert %q to bool", str) + } + } else if boolVal, ok := value.(bool); ok { + field.SetBool(boolVal) + } else { + return fmt.Errorf("cannot convert %T to bool", value) + } + + case reflect.Slice: + return setSliceField(field, value) + + case reflect.Struct: + if dataMap, ok := value.(map[string]interface{}); ok { + return fillStruct(dataMap, field) + } else { + return fmt.Errorf("cannot convert %T to struct", value) + } + + case reflect.Map: + if fieldType.Key().Kind() == reflect.String { + return setMapField(field, value) + } else { + return fmt.Errorf("unsupported map key type: %v", fieldType.Key().Kind()) + } + + default: + // Try direct assignment if types match + if valueReflect.Type().AssignableTo(fieldType) { + field.Set(valueReflect) + } else { + return fmt.Errorf("unsupported field type: %v", fieldType.Kind()) + } + } + + return nil +} + +// setSliceField handles slice field assignment +func setSliceField(field reflect.Value, value interface{}) error { + sliceValue, ok := value.([]interface{}) + if !ok { + // Check if it's a map that can be converted to slice + if mapValue, isMap := value.(map[string]interface{}); isMap { + if canConvertToArray(mapValue) { + sliceValue = convertMapToArray(mapValue) + } else { + // Try to convert single value to slice + sliceValue = []interface{}{value} + } + } else { + // Try to convert single value to slice + sliceValue = []interface{}{value} + } + } + + fieldType := field.Type() + + newSlice := reflect.MakeSlice(fieldType, len(sliceValue), len(sliceValue)) + + for i, item := range sliceValue { + elemField := newSlice.Index(i) + if err := setFieldValue(elemField, item); err != nil { + return fmt.Errorf("error setting slice element %d: %v", i, err) + } + } + + field.Set(newSlice) + return nil +} + +// setMapField handles map field assignment +func setMapField(field reflect.Value, value interface{}) error { + dataMap, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("cannot convert %T to map", value) + } + + fieldType := field.Type() + valueType := fieldType.Elem() + + newMap := reflect.MakeMap(fieldType) + + for k, v := range dataMap { + keyVal := reflect.ValueOf(k) + valueVal := reflect.New(valueType).Elem() + + if err := setFieldValue(valueVal, v); err != nil { + return fmt.Errorf("error setting map value for key %q: %v", k, err) + } + + newMap.SetMapIndex(keyVal, valueVal) + } + + field.Set(newMap) + return nil +} + +// StructToQueryString converts a struct to query string using query tags +func StructToQueryString(obj interface{}, opts ...*StringifyOptions) (string, error) { + data, err := StructToMap(obj) + if err != nil { + return "", err + } + + return Stringify(data, opts...) +} + +// StructToMap converts a struct to map using query tags +func StructToMap(obj interface{}) (map[string]interface{}, error) { + result := make(map[string]interface{}) + + objValue := reflect.ValueOf(obj) + if objValue.Kind() == reflect.Ptr { + objValue = objValue.Elem() + } + + if objValue.Kind() != reflect.Struct { + return nil, fmt.Errorf("object must be a struct or pointer to struct") + } + + objType := objValue.Type() + + for i := 0; i < objValue.NumField(); i++ { + field := objValue.Field(i) + fieldType := objType.Field(i) + + // Skip unexported fields + if !field.CanInterface() { + continue + } + + // Get query tag + queryTag := fieldType.Tag.Get("query") + if queryTag == "" { + // If no query tag, use field name in lowercase + queryTag = strings.ToLower(fieldType.Name) + } + + // Skip fields with query:"-" + if queryTag == "-" { + continue + } + + // Get field value + fieldValue := field.Interface() + + // Skip nil pointers + if field.Kind() == reflect.Ptr && field.IsNil() { + continue + } + + // Handle slices specially to ensure they're properly included even if empty + if field.Kind() == reflect.Slice { + // Convert Go slice to []interface{} for Stringify compatibility + sliceLen := field.Len() + interfaceSlice := make([]interface{}, sliceLen) + for i := 0; i < sliceLen; i++ { + interfaceSlice[i] = field.Index(i).Interface() + } + result[queryTag] = interfaceSlice + } else if field.Kind() == reflect.Struct || + (field.Kind() == reflect.Ptr && field.Elem().Kind() == reflect.Struct) { + // Convert nested structs to maps + nestedMap, err := StructToMap(fieldValue) + if err != nil { + return nil, fmt.Errorf("error converting nested struct field %s: %v", fieldType.Name, err) + } + result[queryTag] = nestedMap + } else { + result[queryTag] = fieldValue + } + } + + return result, nil +} + +// Unmarshal parses a query string and stores the result in the value pointed to by v. +// +// This function provides idiomatic Go unmarshaling with automatic type detection. +// It works with structs, maps, slices, and primitive types, automatically choosing +// the appropriate conversion method based on the target type. +// +// # Parameters +// +// - ctx: Context used to build API errors +// - queryString: The query string to parse (with or without leading "?") +// - v: Pointer to the value where the result should be stored +// - opts: Optional ParseOptions to customize parsing behavior +// +// # Return Values +// +// - *apierrors.BaseAPIError: Any error that occurred during parsing or unmarshaling +// +// # Examples +// +// Unmarshal to struct: +// +// type User struct { +// Name string `query:"name"` +// Age int `query:"age"` +// } +// var user User +// err := qs.Unmarshal(ctx, "name=John&age=30", &user) +// +// Unmarshal to map: +// +// var data map[string]interface{} +// err := qs.Unmarshal(ctx, "name=John&age=30", &data) +// +// Unmarshal to slice: +// +// var tags []string +// err := qs.Unmarshal(ctx, "tags[]=go&tags[]=programming", &tags) +// +// With custom options: +// +// var user User +// err := qs.Unmarshal(ctx, "?name=John&age=30", &user, &qs.ParseOptions{ +// IgnoreQueryPrefix: true, +// }) +// +// # Supported Target Types +// +// - Structs with query tags +// - Maps (map[string]interface{}, map[string]string, etc.) +// - Slices and arrays +// - Primitive types (string, int, float, bool) +// - Pointers (automatically allocated if nil) +// - Interfaces (interface{}) +// +// # Error Handling +// +// Unmarshal returns an error if: +// - The target is nil or not a pointer +// - The target is not settable +// - Type conversion fails +// - The query string is malformed +// - Custom parsing options cause errors +// +// # Performance +// +// This function provides excellent performance with automatic type detection, +// making it suitable for high-throughput applications. +func Unmarshal(ctx context.Context, queryString string, v interface{}, opts ...*ParseOptions) *apierrors.BaseAPIError { + if v == nil { + return newQueryAPIError(ctx, fmt.Errorf("unmarshal target cannot be nil"), "") + } + + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Ptr { + return newQueryAPIError(ctx, fmt.Errorf("unmarshal target must be a pointer, got %T", v), "") + } + + rv = rv.Elem() + if !rv.CanSet() { + return newQueryAPIError(ctx, fmt.Errorf("unmarshal target must be settable"), "") + } + + // Parse the query string to map first + data, err := Parse(ctx, queryString, opts...) + if err != nil { + return err + } + + if uErr := unmarshalValue(data, rv); uErr != nil { + return newQueryAPIError(ctx, uErr, "") + } + + return nil +} + +// Marshal converts a value to a query string. +// +// This function provides idiomatic Go marshaling with automatic type detection. +// It works with structs, maps, slices, and primitive types, automatically choosing +// the appropriate conversion method based on the source type. +// +// # Parameters +// +// - v: The value to convert to a query string +// - opts: Optional StringifyOptions to customize output format +// +// # Return Values +// +// - string: The generated query string +// - error: Any error that occurred during marshaling +// +// # Examples +// +// Marshal struct: +// +// type User struct { +// Name string `query:"name"` +// Age int `query:"age"` +// } +// user := User{Name: "John", Age: 30} +// queryString, err := qs.Marshal(user) +// // Returns: "age=30&name=John" +// +// Marshal map: +// +// data := map[string]interface{}{ +// "name": "John", +// "age": 30, +// } +// queryString, err := qs.Marshal(data) +// // Returns: "age=30&name=John" +// +// Marshal slice: +// +// tags := []string{"go", "programming"} +// queryString, err := qs.Marshal(tags) +// // Returns: "0=go&1=programming" +// +// With custom options: +// +// queryString, err := qs.Marshal(data, &qs.StringifyOptions{ +// ArrayFormat: "brackets", +// AddQueryPrefix: true, +// }) +// +// # Supported Source Types +// +// - Structs with or without query tags +// - Maps (any map with string keys) +// - Slices and arrays +// - Primitive types (string, int, float, bool) +// - Pointers (automatically dereferenced) +// - time.Time values +// - Interfaces (interface{}) +// +// # Error Handling +// +// Marshal returns an error if: +// - Reflection operations fail +// - Unsupported types are encountered +// - Custom encoding functions return errors +// +// # Performance +// +// This function provides excellent performance with automatic type detection, +// making it suitable for high-throughput applications where the source type +// may vary at runtime. +func Marshal(v interface{}, opts ...*StringifyOptions) (string, error) { + if v == nil { + return "", nil + } + + data, err := marshalValue(v) + if err != nil { + return "", err + } + + return Stringify(data, opts...) +} + +// unmarshalValue recursively unmarshals data into a reflect.Value +func unmarshalValue(data interface{}, rv reflect.Value) error { + if data == nil { + return nil + } + + rt := rv.Type() + + // Handle pointers + if rt.Kind() == reflect.Ptr { + if rv.IsNil() { + rv.Set(reflect.New(rt.Elem())) + } + return unmarshalValue(data, rv.Elem()) + } + + switch rt.Kind() { + case reflect.Struct: + return unmarshalStruct(data, rv) + case reflect.Map: + return unmarshalMap(data, rv) + case reflect.Slice: + return unmarshalSlice(data, rv) + case reflect.Interface: + // For interface{}, set the data directly + if rt == reflect.TypeOf((*interface{})(nil)).Elem() { + rv.Set(reflect.ValueOf(data)) + return nil + } + return fmt.Errorf("unsupported interface type: %v", rt) + default: + // Handle primitive types + return setFieldValue(rv, data) + } +} + +// unmarshalStruct unmarshals data into a struct +func unmarshalStruct(data interface{}, rv reflect.Value) error { + dataMap, ok := data.(map[string]interface{}) + if !ok { + return fmt.Errorf("cannot unmarshal %T into struct", data) + } + + return fillStruct(dataMap, rv) +} + +// unmarshalMap unmarshals data into a map +func unmarshalMap(data interface{}, rv reflect.Value) error { + dataMap, ok := data.(map[string]interface{}) + if !ok { + return fmt.Errorf("cannot unmarshal %T into map", data) + } + + rt := rv.Type() + keyType := rt.Key() + valueType := rt.Elem() + + // Only support string keys for now + if keyType.Kind() != reflect.String { + return fmt.Errorf("unsupported map key type: %v", keyType) + } + + if rv.IsNil() { + rv.Set(reflect.MakeMap(rt)) + } + + for k, v := range dataMap { + keyVal := reflect.ValueOf(k) + valueVal := reflect.New(valueType).Elem() + + if err := unmarshalValue(v, valueVal); err != nil { + return fmt.Errorf("error unmarshaling map value for key %q: %v", k, err) + } + + rv.SetMapIndex(keyVal, valueVal) + } + + return nil +} + +// unmarshalSlice unmarshals data into a slice +func unmarshalSlice(data interface{}, rv reflect.Value) error { + // Handle different slice data formats + var sliceData []interface{} + + switch v := data.(type) { + case []interface{}: + sliceData = v + case map[string]interface{}: + // Convert map with numeric keys to slice + if !canConvertToArray(v) { + return fmt.Errorf("cannot unmarshal map into slice: non-numeric keys found") + } + sliceData = convertMapToArray(v) + default: + // Single value becomes slice with one element + sliceData = []interface{}{data} + } + + rt := rv.Type() + + newSlice := reflect.MakeSlice(rt, len(sliceData), len(sliceData)) + + for i, item := range sliceData { + elemVal := newSlice.Index(i) + if err := unmarshalValue(item, elemVal); err != nil { + return fmt.Errorf("error unmarshaling slice element %d: %v", i, err) + } + } + + rv.Set(newSlice) + return nil +} + +// marshalValue converts a value to a format suitable for Stringify +func marshalValue(v interface{}) (interface{}, error) { + if v == nil { + return nil, nil + } + + rv := reflect.ValueOf(v) + return marshalReflectValue(rv) +} + +// marshalReflectValue converts a reflect.Value to a format suitable for Stringify +func marshalReflectValue(rv reflect.Value) (interface{}, error) { + // Handle pointers + if rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return nil, nil + } + return marshalReflectValue(rv.Elem()) + } + + switch rv.Kind() { + case reflect.Struct: + return marshalStruct(rv) + case reflect.Map: + return marshalMap(rv) + case reflect.Slice: + return marshalSlice(rv) + case reflect.Interface: + if rv.IsNil() { + return nil, nil + } + return marshalReflectValue(rv.Elem()) + default: + // Return primitive values as-is + return rv.Interface(), nil + } +} + +// marshalStruct converts a struct to a map using query tags +func marshalStruct(rv reflect.Value) (map[string]interface{}, error) { + result := make(map[string]interface{}) + rt := rv.Type() + + for i := 0; i < rv.NumField(); i++ { + field := rv.Field(i) + fieldType := rt.Field(i) + + // Skip unexported fields + if !field.CanInterface() { + continue + } + + // Get query tag + queryTag := fieldType.Tag.Get("query") + if queryTag == "" { + // If no query tag, use field name in lowercase + queryTag = strings.ToLower(fieldType.Name) + } + + // Skip fields with query:"-" + if queryTag == "-" { + continue + } + + // Skip nil pointers + if field.Kind() == reflect.Ptr && field.IsNil() { + continue + } + + // Marshal field value + fieldValue, err := marshalReflectValue(field) + if err != nil { + return nil, fmt.Errorf("error marshaling field %s: %v", fieldType.Name, err) + } + + if fieldValue != nil { + result[queryTag] = fieldValue + } + } + + return result, nil +} + +// marshalMap converts a map to a format suitable for Stringify +func marshalMap(rv reflect.Value) (map[string]interface{}, error) { + if rv.IsNil() { + return nil, nil + } + + result := make(map[string]interface{}) + + for _, key := range rv.MapKeys() { + keyStr := fmt.Sprintf("%v", key.Interface()) + value := rv.MapIndex(key) + + marshaledValue, err := marshalReflectValue(value) + if err != nil { + return nil, fmt.Errorf("error marshaling map value for key %q: %v", keyStr, err) + } + + if marshaledValue != nil { + result[keyStr] = marshaledValue + } + } + + return result, nil +} + +// marshalSlice converts a slice to []interface{} +func marshalSlice(rv reflect.Value) ([]interface{}, error) { + if rv.IsNil() { + return nil, nil + } + + result := make([]interface{}, rv.Len()) + + for i := 0; i < rv.Len(); i++ { + elem := rv.Index(i) + marshaledElem, err := marshalReflectValue(elem) + if err != nil { + return nil, fmt.Errorf("error marshaling slice element %d: %v", i, err) + } + result[i] = marshaledElem + } + + return result, nil +} diff --git a/api/v3/request/query_test.go b/api/v3/request/query_test.go new file mode 100644 index 0000000000..9454670fd7 --- /dev/null +++ b/api/v3/request/query_test.go @@ -0,0 +1,999 @@ +package request + +import ( + "context" + "reflect" + "strings" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + want map[string]interface{} + wantErr bool + }{ + { + name: "simple key-value", + input: "a=b", + want: map[string]interface{}{ + "a": "b", + }, + }, + { + name: "nested object", + input: "a[b][c]=d", + want: map[string]interface{}{ + "a": map[string]interface{}{ + "b": map[string]interface{}{ + "c": "d", + }, + }, + }, + }, + { + name: "array", + input: "a[]=b&a[]=c", + want: map[string]interface{}{ + "a": []interface{}{"b", "c"}, + }, + }, + { + name: "nested array", + input: "a[b][]=c&a[b][]=d", + want: map[string]interface{}{ + "a": map[string]interface{}{ + "b": []interface{}{"c", "d"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(t.Context(), tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseComplex(t *testing.T) { + tests := []struct { + name string + input string + want map[string]interface{} + wantErr bool + }{ + { + name: "deep nested objects", + input: "user[profile][settings][theme][colors][primary]=blue&user[profile][settings][theme][colors][secondary]=green", + want: map[string]interface{}{ + "user": map[string]interface{}{ + "profile": map[string]interface{}{ + "settings": map[string]interface{}{ + "theme": map[string]interface{}{ + "colors": map[string]interface{}{ + "primary": "blue", + "secondary": "green", + }, + }, + }, + }, + }, + }, + }, + { + name: "array of objects", + input: "users[0][name]=John&users[0][age]=30&users[1][name]=Jane&users[1][age]=25", + want: map[string]interface{}{ + "users": map[string]interface{}{ + "0": map[string]interface{}{ + "name": "John", + "age": "30", + }, + "1": map[string]interface{}{ + "name": "Jane", + "age": "25", + }, + }, + }, + }, + { + name: "mixed arrays and objects", + input: "data[users][]=John&data[users][]=Jane&data[settings][theme]=dark&data[config][api][endpoints][]=auth&data[config][api][endpoints][]=users", + want: map[string]interface{}{ + "data": map[string]interface{}{ + "users": []interface{}{"John", "Jane"}, + "settings": map[string]interface{}{ + "theme": "dark", + }, + "config": map[string]interface{}{ + "api": map[string]interface{}{ + "endpoints": []interface{}{"auth", "users"}, + }, + }, + }, + }, + }, + { + name: "complex e-commerce query", + input: "products[0][id]=123&products[0][name]=Laptop&products[0][price]=999&products[0][tags][]=electronics&products[0][tags][]=computers&products[0][variants][0][size]=15inch&products[0][variants][0][color]=black&products[1][id]=456&products[1][name]=Mouse&products[1][price]=25&filters[category]=electronics&filters[price][min]=0&filters[price][max]=1000&sort[field]=price&sort[order]=asc", + want: map[string]interface{}{ + "products": map[string]interface{}{ + "0": map[string]interface{}{ + "id": "123", + "name": "Laptop", + "price": "999", + "tags": []interface{}{"electronics", "computers"}, + "variants": map[string]interface{}{ + "0": map[string]interface{}{ + "size": "15inch", + "color": "black", + }, + }, + }, + "1": map[string]interface{}{ + "id": "456", + "name": "Mouse", + "price": "25", + }, + }, + "filters": map[string]interface{}{ + "category": "electronics", + "price": map[string]interface{}{ + "min": "0", + "max": "1000", + }, + }, + "sort": map[string]interface{}{ + "field": "price", + "order": "asc", + }, + }, + }, + { + name: "empty values and arrays", + input: "empty=&arr[]=&arr[]=value&nested[empty]=&nested[arr][]=", + want: map[string]interface{}{ + "empty": "", + "arr": []interface{}{"", "value"}, + "nested": map[string]interface{}{ + "empty": "", + "arr": []interface{}{""}, + }, + }, + }, + { + name: "url encoded values", + input: "message=Hello%20World&symbols=%21%40%23%24%25&unicode=%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82", + want: map[string]interface{}{ + "message": "Hello World", + "symbols": "!@#$%", + "unicode": "Привет", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(t.Context(), tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseWithOptions(t *testing.T) { + ctx := t.Context() + // Test with custom delimiter + result, err := Parse(ctx, "name=John;age=30;city=NYC", &ParseOptions{ + Delimiter: ";", + }) + if err != nil { + t.Errorf("Parse with custom delimiter failed: %v", err) + } + + expected := map[string]interface{}{ + "name": "John", + "age": "30", + "city": "NYC", + } + + if !reflect.DeepEqual(result, expected) { + t.Errorf("Parse with custom delimiter = %v, want %v", result, expected) + } + + // Test with parameter limit + longQuery := strings.Repeat("param=value&", 1001) + longQuery = strings.TrimSuffix(longQuery, "&") + + _, err = Parse(ctx, longQuery, &ParseOptions{ + ParameterLimit: 100, + ThrowOnLimitExceeded: true, + }) + if err == nil { + t.Error("Expected error when exceeding parameter limit") + } + + // Test without throwing on limit exceeded + result2, err := Parse(ctx, longQuery, &ParseOptions{ + ParameterLimit: 100, + ThrowOnLimitExceeded: false, + }) + if err != nil { + t.Errorf("Parse without throwing on limit exceeded failed: %v", err) + } + if len(result2) != 1 { // Should only have one key "param" with last value + t.Errorf("Expected 1 key in result, got %d", len(result2)) + } +} + +func TestStringify(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want string + wantErr bool + }{ + { + name: "simple object", + input: map[string]interface{}{ + "a": "b", + "c": "d", + }, + want: "a=b&c=d", + }, + { + name: "nested object", + input: map[string]interface{}{ + "a": map[string]interface{}{ + "b": "c", + }, + }, + want: "a[b]=c", + }, + { + name: "array", + input: map[string]interface{}{ + "a": []interface{}{"b", "c"}, + }, + want: "a[0]=b&a[1]=c", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Stringify(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Stringify() error = %v, wantErr %v", err, tt.wantErr) + return + } + // Since order is not guaranteed in maps, we check if all expected parts are present + if !tt.wantErr { + parts := strings.Split(tt.want, "&") + for _, part := range parts { + if !strings.Contains(got, part) { + t.Errorf("Stringify() = %v, should contain %v", got, part) + } + } + } + }) + } +} + +func TestStringifyComplex(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want []string // Parts that should be present + wantErr bool + }{ + { + name: "deep nested structure", + input: map[string]interface{}{ + "user": map[string]interface{}{ + "profile": map[string]interface{}{ + "name": "John", + "settings": map[string]interface{}{ + "theme": "dark", + }, + }, + }, + }, + want: []string{"user[profile][name]=John", "user[profile][settings][theme]=dark"}, + }, + { + name: "various data types", + input: map[string]interface{}{ + "string": "hello", + "number": 42, + "bool": true, + "array": []interface{}{"a", "b"}, + }, + want: []string{"string=hello", "number=42", "bool=true", "array[0]=a", "array[1]=b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Stringify(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Stringify() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + for _, want := range tt.want { + if !strings.Contains(got, want) { + t.Errorf("Stringify() = %v, should contain %v", got, want) + } + } + } + }) + } +} + +func TestStringifyWithOptionsSimple(t *testing.T) { + input := map[string]interface{}{ + "name": "John Doe", + "age": 30, + } + + // Test with query prefix + result, err := Stringify(input, &StringifyOptions{ + AddQueryPrefix: true, + }) + if err != nil { + t.Errorf("Stringify with AddQueryPrefix failed: %v", err) + } + if !strings.HasPrefix(result, "?") { + t.Errorf("Expected result to start with '?', got %s", result) + } + + // Test with custom delimiter + result2, err := Stringify(input, &StringifyOptions{ + Delimiter: ";", + }) + if err != nil { + t.Errorf("Stringify with custom delimiter failed: %v", err) + } + if !strings.Contains(result2, ";") { + t.Errorf("Expected result to contain ';', got %s", result2) + } +} + +// Test structures for struct parsing +type User struct { + Name string `query:"name"` + Age int `query:"age"` + Email string `query:"email"` + IsActive bool `query:"active"` + Score float64 `query:"score"` +} + +type SearchFilter struct { + Query string `query:"q"` + Tags []string `query:"tags"` + Category string `query:"category"` + MinPrice int `query:"min_price"` + MaxPrice int `query:"max_price"` +} + +type NestedStruct struct { + User User `query:"user"` + Settings map[string]string `query:"settings"` + Enabled bool `query:"enabled"` +} + +type ProductVariant struct { + Size string `query:"size"` + Color string `query:"color"` +} + +type Product struct { + ID int `query:"id"` + Name string `query:"name"` + Price float64 `query:"price"` + Tags []string `query:"tags"` + Variants []ProductVariant `query:"variants"` +} + +func TestParseToStruct(t *testing.T) { + tests := []struct { + name string + input string + dest interface{} + expected interface{} + wantErr bool + }{ + { + name: "simple user struct", + input: "name=John&age=30&email=john@example.com&active=true&score=95.5", + dest: &User{}, + expected: &User{ + Name: "John", + Age: 30, + Email: "john@example.com", + IsActive: true, + Score: 95.5, + }, + }, + { + name: "search filter with arrays", + input: "q=golang&tags[]=programming&tags[]=web&category=tech&min_price=10&max_price=100", + dest: &SearchFilter{}, + expected: &SearchFilter{ + Query: "golang", + Tags: []string{"programming", "web"}, + Category: "tech", + MinPrice: 10, + MaxPrice: 100, + }, + }, + { + name: "nested struct", + input: "user[name]=Alice&user[age]=25&user[email]=alice@test.com&user[active]=false&user[score]=88.0&settings[theme]=dark&settings[lang]=en&enabled=true", + dest: &NestedStruct{}, + expected: &NestedStruct{ + User: User{ + Name: "Alice", + Age: 25, + Email: "alice@test.com", + IsActive: false, + Score: 88.0, + }, + Settings: map[string]string{ + "theme": "dark", + "lang": "en", + }, + Enabled: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ParseToStruct(t.Context(), tt.input, tt.dest) + if (err != nil) != tt.wantErr { + t.Errorf("ParseToStruct() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(tt.dest, tt.expected) { + t.Errorf("ParseToStruct() = %+v, want %+v", tt.dest, tt.expected) + } + }) + } +} + +func TestStructToQueryString(t *testing.T) { + tests := []struct { + name string + input interface{} + contains []string // Check if result contains these substrings instead of exact match + wantErr bool + }{ + { + name: "simple user struct", + input: &User{ + Name: "John", + Age: 30, + Email: "john@example.com", + IsActive: true, + Score: 95.5, + }, + contains: []string{"active=true", "age=30", "email=john%40example.com", "name=John", "score=95.5"}, + }, + { + name: "search filter with arrays", + input: &SearchFilter{ + Query: "golang programming", + Tags: []string{"web", "api"}, + Category: "tech", + MinPrice: 10, + MaxPrice: 100, + }, + contains: []string{ + "category=tech", "max_price=100", "min_price=10", + "q=golang%20programming", "tags[0]=web", "tags[1]=api", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := StructToQueryString(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("StructToQueryString() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + for _, substr := range tt.contains { + if !strings.Contains(got, substr) { + t.Errorf("StructToQueryString() = %v, expected to contain %v", got, substr) + } + } + } + }) + } +} + +func TestMapToStruct(t *testing.T) { + data := map[string]interface{}{ + "name": "Bob", + "age": "35", + "email": "bob@test.com", + "active": "true", + "score": "92.3", + } + + var user User + err := MapToStruct(t.Context(), data, &user) + if err != nil { + t.Fatalf("MapToStruct() error = %v", err) + } + + expected := User{ + Name: "Bob", + Age: 35, + Email: "bob@test.com", + IsActive: true, + Score: 92.3, + } + + if !reflect.DeepEqual(user, expected) { + t.Errorf("MapToStruct() = %+v, want %+v", user, expected) + } +} + +func TestStructToMap(t *testing.T) { + user := &User{ + Name: "Charlie", + Age: 28, + Email: "charlie@example.com", + IsActive: false, + Score: 78.9, + } + + got, err := StructToMap(user) + if err != nil { + t.Fatalf("StructToMap() error = %v", err) + } + + expected := map[string]interface{}{ + "name": "Charlie", + "age": 28, + "email": "charlie@example.com", + "active": false, + "score": 78.9, + } + + if !reflect.DeepEqual(got, expected) { + t.Errorf("StructToMap() = %+v, want %+v", got, expected) + } +} + +func BenchmarkParseSimple(b *testing.B) { + for i := 0; i < b.N; i++ { + Parse(context.Background(), "a=b&c=d&e=f") + } +} + +func BenchmarkParseComplex(b *testing.B) { + for i := 0; i < b.N; i++ { + Parse(context.Background(), "user[profile][settings][theme][colors][primary]=blue&user[profile][settings][theme][colors][secondary]=green&data[users][]=John&data[users][]=Jane") + } +} + +func BenchmarkStringifySimple(b *testing.B) { + obj := map[string]interface{}{ + "a": "b", + "c": "d", + "e": "f", + } + + for i := 0; i < b.N; i++ { + Stringify(obj) + } +} + +func BenchmarkStringifyComplex(b *testing.B) { + obj := map[string]interface{}{ + "products": map[string]interface{}{ + "0": map[string]interface{}{ + "id": "123", + "name": "Laptop", + "price": "999", + "tags": []interface{}{"electronics", "computers"}, + "variants": map[string]interface{}{ + "0": map[string]interface{}{ + "size": "15inch", + "color": "black", + }, + }, + }, + "1": map[string]interface{}{ + "id": "456", + "name": "Mouse", + "price": "25", + }, + }, + "filters": map[string]interface{}{ + "category": "electronics", + "price": map[string]interface{}{ + "min": "0", + "max": "1000", + }, + }, + "sort": map[string]interface{}{ + "field": "price", + "order": "asc", + }, + } + + for i := 0; i < b.N; i++ { + _, _ = Stringify(obj) + } +} + +func BenchmarkParseToStruct(b *testing.B) { + queryString := "name=John&age=30&email=john@example.com&active=true&score=95.5" + + for i := 0; i < b.N; i++ { + var user User + _ = ParseToStruct(context.Background(), queryString, &user) + } +} + +func BenchmarkStructToQueryString(b *testing.B) { + user := &User{ + Name: "John", + Age: 30, + Email: "john@example.com", + IsActive: true, + Score: 95.5, + } + + for i := 0; i < b.N; i++ { + _, _ = StructToQueryString(user) + } +} + +// Tests for Marshal/Unmarshal functions +func TestUnmarshal(t *testing.T) { + tests := []struct { + name string + query string + target interface{} + expected interface{} + wantErr bool + }{ + { + name: "unmarshal to struct", + query: "name=John&age=30&email=john@example.com&active=true&score=95.5", + target: &User{}, + expected: &User{ + Name: "John", + Age: 30, + Email: "john@example.com", + IsActive: true, + Score: 95.5, + }, + }, + { + name: "unmarshal to map", + query: "name=John&age=30&city=NYC", + target: &map[string]interface{}{}, + expected: &map[string]interface{}{ + "name": "John", + "age": "30", + "city": "NYC", + }, + }, + { + name: "unmarshal to struct with nested map", + query: "user[name]=Alice&user[age]=25&settings[theme]=dark&settings[lang]=en&enabled=true", + target: &NestedStruct{}, + expected: &NestedStruct{ + User: User{ + Name: "Alice", + Age: 25, + }, + Settings: map[string]string{ + "theme": "dark", + "lang": "en", + }, + Enabled: true, + }, + }, + { + name: "unmarshal to struct with slice", + query: "q=golang&tags[]=programming&tags[]=web&category=tech&min_price=10&max_price=100", + target: &SearchFilter{}, + expected: &SearchFilter{ + Query: "golang", + Tags: []string{"programming", "web"}, + Category: "tech", + MinPrice: 10, + MaxPrice: 100, + }, + }, + { + name: "unmarshal to map with complex structure", + query: "users[0][name]=John&users[0][age]=30&users[1][name]=Jane&users[1][age]=25&metadata[total]=2", + target: &map[string]interface{}{}, + expected: &map[string]interface{}{ + "users": map[string]interface{}{ + "0": map[string]interface{}{ + "name": "John", + "age": "30", + }, + "1": map[string]interface{}{ + "name": "Jane", + "age": "25", + }, + }, + "metadata": map[string]interface{}{ + "total": "2", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Unmarshal(t.Context(), tt.query, tt.target) + if (err != nil) != tt.wantErr { + t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(tt.target, tt.expected) { + t.Errorf("Unmarshal() = %+v, want %+v", tt.target, tt.expected) + } + }) + } +} + +func TestMarshal(t *testing.T) { + tests := []struct { + name string + input interface{} + contains []string // Check if result contains these substrings + wantErr bool + }{ + { + name: "marshal struct", + input: User{ + Name: "John", + Age: 30, + Email: "john@example.com", + IsActive: true, + Score: 95.5, + }, + contains: []string{"name=John", "age=30", "active=true", "score=95.5"}, + }, + { + name: "marshal map", + input: map[string]interface{}{ + "name": "Alice", + "age": 25, + "city": "NYC", + }, + contains: []string{"name=Alice", "age=25", "city=NYC"}, + }, + { + name: "marshal struct with slice", + input: SearchFilter{ + Query: "golang", + Tags: []string{"programming", "web"}, + Category: "tech", + MinPrice: 10, + MaxPrice: 100, + }, + contains: []string{"q=golang", "tags[0]=programming", "tags[1]=web", "category=tech", "min_price=10", "max_price=100"}, + }, + { + name: "marshal nested struct", + input: NestedStruct{ + User: User{ + Name: "Bob", + Age: 35, + }, + Settings: map[string]string{ + "theme": "light", + "lang": "en", + }, + Enabled: true, + }, + contains: []string{"user[name]=Bob", "user[age]=35", "settings[theme]=light", "settings[lang]=en", "enabled=true"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Marshal(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Marshal() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + for _, substr := range tt.contains { + if !strings.Contains(got, substr) { + t.Errorf("Marshal() = %v, expected to contain %v", got, substr) + } + } + } + }) + } +} + +func TestMarshalUnmarshalRoundTrip(t *testing.T) { + tests := []struct { + name string + input interface{} + }{ + { + name: "simple struct", + input: User{ + Name: "John", + Age: 30, + Email: "john@example.com", + IsActive: true, + Score: 95.5, + }, + }, + { + name: "struct with slice", + input: SearchFilter{ + Query: "golang", + Tags: []string{"programming", "web", "backend"}, + Category: "tech", + MinPrice: 10, + MaxPrice: 100, + }, + }, + { + name: "nested struct", + input: NestedStruct{ + User: User{ + Name: "Alice", + Age: 25, + Email: "alice@example.com", + IsActive: false, + Score: 88.0, + }, + Settings: map[string]string{ + "theme": "dark", + "lang": "ru", + }, + Enabled: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + // Marshal to query string + queryString, err := Marshal(tt.input) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + // Create a new instance of the same type + targetType := reflect.TypeOf(tt.input) + target := reflect.New(targetType).Interface() + + // Unmarshal back + if apiErr := Unmarshal(ctx, queryString, target); apiErr != nil { + t.Fatalf("Unmarshal() error = %v (%T) underlying=%#v invalid=%#v", apiErr, apiErr, apiErr.Unwrap(), apiErr.InvalidParameters) + } + + // Compare (dereference pointer) + targetValue := reflect.ValueOf(target).Elem().Interface() + if !reflect.DeepEqual(tt.input, targetValue) { + t.Errorf("Round trip failed: original = %+v, result = %+v", tt.input, targetValue) + } + }) + } +} + +func TestUnmarshalErrors(t *testing.T) { + tests := []struct { + name string + query string + target interface{} + wantErr bool + }{ + { + name: "nil target", + query: "name=John", + target: nil, + wantErr: true, + }, + { + name: "non-pointer target", + query: "name=John", + target: User{}, + wantErr: true, + }, + { + name: "unsettable target", + query: "name=John", + target: (*User)(nil), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Unmarshal(t.Context(), tt.query, tt.target) + if (err != nil) != tt.wantErr { + t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func BenchmarkMarshal(b *testing.B) { + user := User{ + Name: "John", + Age: 30, + Email: "john@example.com", + IsActive: true, + Score: 95.5, + } + + for i := 0; i < b.N; i++ { + _, _ = Marshal(user) + } +} + +func BenchmarkUnmarshal(b *testing.B) { + queryString := "name=John&age=30&email=john@example.com&active=true&score=95.5" + + for i := 0; i < b.N; i++ { + var user User + _ = Unmarshal(context.Background(), queryString, &user) + } +} + +func BenchmarkMarshalComplex(b *testing.B) { + nested := NestedStruct{ + User: User{ + Name: "Alice", + Age: 25, + Email: "alice@example.com", + IsActive: false, + Score: 88.0, + }, + Settings: map[string]string{ + "theme": "dark", + "lang": "ru", + }, + Enabled: true, + } + + for i := 0; i < b.N; i++ { + _, _ = Marshal(nested) + } +} + +func BenchmarkUnmarshalComplex(b *testing.B) { + queryString := "user[name]=Alice&user[age]=25&user[email]=alice@example.com&user[active]=false&user[score]=88.0&settings[theme]=dark&settings[lang]=ru&enabled=true" + + for i := 0; i < b.N; i++ { + var nested NestedStruct + _ = Unmarshal(context.Background(), queryString, &nested) + } +} From 28dfabb15a16c1d14e2f0042eac4671cdb568f93 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:53:56 +0100 Subject: [PATCH 08/18] chore: WIP --- api/spec/src/v3/meters/operations.tsp | 11 +- api/spec/src/v3/shared/properties.tsp | 10 + api/v3/api.gen.go | 313 ++++++++++++++++---------- api/v3/openapi.yaml | 15 +- api/v3/request/filter.go | 14 ++ api/v3/request/pagination.go | 8 +- api/v3/request/request.go | 42 ++++ api/v3/request/sort.go | 60 +++++ api/v3/server/meters.go | 2 +- 9 files changed, 343 insertions(+), 132 deletions(-) create mode 100644 api/v3/request/filter.go create mode 100644 api/v3/request/request.go create mode 100644 api/v3/request/sort.go diff --git a/api/spec/src/v3/meters/operations.tsp b/api/spec/src/v3/meters/operations.tsp index 26a59bea2b..54da4516bc 100644 --- a/api/spec/src/v3/meters/operations.tsp +++ b/api/spec/src/v3/meters/operations.tsp @@ -14,6 +14,9 @@ using TypeSpec.OpenAPI; namespace Meters; interface MetersOperations { + /** + * Create a meter. + */ @post @operationId("create-meter") @summary("Create meter") @@ -21,13 +24,19 @@ interface MetersOperations { @body meter: Shared.CreateRequest, ): Shared.CreateResponse | Common.ErrorResponses; + /** + * Get a meter by ID or key. + */ @get @operationId("get-meter") @summary("Get meter") get( - @path meterId: Shared.ULID, + @path meterIdOrKey: Shared.ULIDOrResourceKey, ): Shared.GetResponse | Common.NotFound | Common.ErrorResponses; + /** + * List meters. + */ @get @operationId("list-meters") @summary("List meters") diff --git a/api/spec/src/v3/shared/properties.tsp b/api/spec/src/v3/shared/properties.tsp index 3eee2f9f3a..f66de9ed90 100644 --- a/api/spec/src/v3/shared/properties.tsp +++ b/api/spec/src/v3/shared/properties.tsp @@ -26,6 +26,16 @@ scalar ULID extends string; @example("resource_key") scalar ResourceKey extends string; +/** + * ULID ID or Resource Key. + */ +@friendlyName("ULIDOrResourceKey") +@summary("ULID ID or Resource Key") +union ULIDOrResourceKey { + id: ULID, + key: ResourceKey, +} + /** * ExternalResourceKey is a unique string that is used to identify a resource in an external system. */ diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index 8346f14cc9..80b074e54e 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -647,6 +647,11 @@ type StringFieldOEQFilter struct { // ULID ULID (Universally Unique Lexicographically Sortable Identifier). type ULID = string +// ULIDOrResourceKey ULID ID or Resource Key. +type ULIDOrResourceKey struct { + union json.RawMessage +} + // UnauthorizedError defines model for UnauthorizedError. type UnauthorizedError struct { Detail interface{} `json:"detail"` @@ -1344,6 +1349,68 @@ func (t *StringFieldFilter) UnmarshalJSON(b []byte) error { return err } +// AsULID returns the union data inside the ULIDOrResourceKey as a ULID +func (t ULIDOrResourceKey) AsULID() (ULID, error) { + var body ULID + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromULID overwrites any union data inside the ULIDOrResourceKey as the provided ULID +func (t *ULIDOrResourceKey) FromULID(v ULID) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeULID performs a merge with any union data inside the ULIDOrResourceKey, using the provided ULID +func (t *ULIDOrResourceKey) MergeULID(v ULID) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsResourceKey returns the union data inside the ULIDOrResourceKey as a ResourceKey +func (t ULIDOrResourceKey) AsResourceKey() (ResourceKey, error) { + var body ResourceKey + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromResourceKey overwrites any union data inside the ULIDOrResourceKey as the provided ResourceKey +func (t *ULIDOrResourceKey) FromResourceKey(v ResourceKey) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeResourceKey performs a merge with any union data inside the ULIDOrResourceKey, using the provided ResourceKey +func (t *ULIDOrResourceKey) MergeResourceKey(v ResourceKey) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t ULIDOrResourceKey) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *ULIDOrResourceKey) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // ServerInterface represents all server handlers. type ServerInterface interface { // List customers @@ -1374,8 +1441,8 @@ type ServerInterface interface { // (POST /openmeter/meters) CreateMeter(w http.ResponseWriter, r *http.Request) // Get meter - // (GET /openmeter/meters/{meterId}) - GetMeter(w http.ResponseWriter, r *http.Request, meterId ULID) + // (GET /openmeter/meters/{meterIdOrKey}) + GetMeter(w http.ResponseWriter, r *http.Request, meterIdOrKey ULIDOrResourceKey) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -1437,8 +1504,8 @@ func (_ Unimplemented) CreateMeter(w http.ResponseWriter, r *http.Request) { } // Get meter -// (GET /openmeter/meters/{meterId}) -func (_ Unimplemented) GetMeter(w http.ResponseWriter, r *http.Request, meterId ULID) { +// (GET /openmeter/meters/{meterIdOrKey}) +func (_ Unimplemented) GetMeter(w http.ResponseWriter, r *http.Request, meterIdOrKey ULIDOrResourceKey) { w.WriteHeader(http.StatusNotImplemented) } @@ -1668,17 +1735,17 @@ func (siw *ServerInterfaceWrapper) GetMeter(w http.ResponseWriter, r *http.Reque var err error - // ------------- Path parameter "meterId" ------------- - var meterId ULID + // ------------- Path parameter "meterIdOrKey" ------------- + var meterIdOrKey ULIDOrResourceKey - err = runtime.BindStyledParameterWithOptions("simple", "meterId", chi.URLParam(r, "meterId"), &meterId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + err = runtime.BindStyledParameterWithOptions("simple", "meterIdOrKey", chi.URLParam(r, "meterIdOrKey"), &meterIdOrKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "meterId", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "meterIdOrKey", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetMeter(w, r, meterId) + siw.Handler.GetMeter(w, r, meterIdOrKey) })) for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { @@ -1829,7 +1896,7 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Post(options.BaseURL+"/openmeter/meters", wrapper.CreateMeter) }) r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/openmeter/meters/{meterId}", wrapper.GetMeter) + r.Get(options.BaseURL+"/openmeter/meters/{meterIdOrKey}", wrapper.GetMeter) }) return r @@ -1838,118 +1905,120 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index 2c761ac0b8..aa472dfe32 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -264,6 +264,7 @@ paths: post: operationId: create-meter summary: Create meter + description: Create a meter. responses: '201': description: Meter created response. @@ -292,6 +293,7 @@ paths: get: operationId: list-meters summary: List meters + description: List meters. parameters: - $ref: '#/components/parameters/CursorPageQuery' responses: @@ -313,16 +315,17 @@ paths: $ref: '#/components/responses/NotAvailable' tags: - Meters - /openmeter/meters/{meterId}: + /openmeter/meters/{meterIdOrKey}: get: operationId: get-meter summary: Get meter + description: Get a meter by ID or key. parameters: - - name: meterId + - name: meterIdOrKey in: path required: true schema: - $ref: '#/components/schemas/ULID' + $ref: '#/components/schemas/ULIDOrResourceKey' responses: '200': description: Meter response. @@ -896,6 +899,12 @@ components: description: ULID (Universally Unique Lexicographically Sortable Identifier). title: ULID example: 01G65Z755AFWAKHE12NY0CQ9FH + ULIDOrResourceKey: + anyOf: + - $ref: '#/components/schemas/ULID' + - $ref: '#/components/schemas/ResourceKey' + description: ULID ID or Resource Key. + title: ULID ID or Resource Key UpdateCustomerRequest: type: object properties: diff --git a/api/v3/request/filter.go b/api/v3/request/filter.go new file mode 100644 index 0000000000..a32eea99f3 --- /dev/null +++ b/api/v3/request/filter.go @@ -0,0 +1,14 @@ +package request + +type Filter struct { + eq *string `query:"eq"` + neq *string `query:"neq"` + gt *string `query:"gt"` + gte *string `query:"gte"` + lt *string `query:"lt"` + lte *string `query:"lte"` + contains *[]string `query:"contains"` + ocontains *[]string `query:"ocontains"` + exists *bool `query:"exists"` + oeq *string `query:"oeq"` +} diff --git a/api/v3/request/pagination.go b/api/v3/request/pagination.go index 8308e5ab53..b97dac5259 100644 --- a/api/v3/request/pagination.go +++ b/api/v3/request/pagination.go @@ -8,8 +8,6 @@ import ( const ( DefaultPaginationSize = 20 - PageBeforeQuery = "page[before]" - PageAfterQuery = "page[after]" ) var ( @@ -19,9 +17,9 @@ var ( ) type CursorPagination struct { - Size int - After *pagination.Cursor - Before *pagination.Cursor + Size int `query:"size"` + After *pagination.Cursor `query:"after"` + Before *pagination.Cursor `query:"before"` } func (p *CursorPagination) Validate() error { diff --git a/api/v3/request/request.go b/api/v3/request/request.go new file mode 100644 index 0000000000..7d8610d3c0 --- /dev/null +++ b/api/v3/request/request.go @@ -0,0 +1,42 @@ +package request + +import "net/http" + +type QueryAttributes struct { + Pagination CursorPagination + Filters []Filter + Sorts []SortBy +} + +// GetAttributes return the Attributes found in the request query string +func GetAttributes(r *http.Request) (*QueryAttributes, error) { + a := &AipAttributes{} + + conf := newConfig() + for _, v := range opts { + v(conf) + } + + queryValues := r.URL.Query() + + pagination, err := extractPagination(r.Context(), queryValues, conf) + if err != nil { + return nil, err + } + a.Pagination = pagination + + filters, err := extractFilter(r.Context(), queryValues, conf) + if err != nil { + return nil, err + } + a.Filters = filters + + sort, err := extractSort(queryValues, conf) + if err != nil { + return nil, err + } + + a.Sorts = sort + + return a, nil +} diff --git a/api/v3/request/sort.go b/api/v3/request/sort.go new file mode 100644 index 0000000000..38f7b85981 --- /dev/null +++ b/api/v3/request/sort.go @@ -0,0 +1,60 @@ +package request + +import ( + "errors" + "strings" +) + +type SortOrder string + +const ( + SortOrderAsc SortOrder = "asc" + SortOrderDesc SortOrder = "desc" +) + +var ( + ErrSortByInvalid = errors.New("invalid sort by") + ErrSortFieldRequired = errors.New("field is required") + ErrSortOrderInvalid = errors.New("sort order must be either asc or desc") + defaultOrder = SortOrderAsc +) + +func (s SortOrder) Validate() error { + if s != SortOrderAsc && s != SortOrderDesc { + return ErrSortOrderInvalid + } + return nil +} + +type SortBy struct { + Field string `query:"field"` + Order SortOrder `query:"order"` +} + +func (s SortBy) Validate() error { + if s.Field == "" { + return ErrSortFieldRequired + } + + if err := s.Order.Validate(); err != nil { + return err + } + + return nil +} + +func (s *SortBy) UnmarshalText(text []byte) error { + parts := strings.Split(string(text), " ") + if len(parts) > 2 { + return ErrSortByInvalid + } + + s.Field = parts[0] + if len(parts) == 2 { + s.Order = SortOrder(parts[1]) + } else { + s.Order = defaultOrder + } + + return s.Validate() +} diff --git a/api/v3/server/meters.go b/api/v3/server/meters.go index 4b1674f506..2aca740966 100644 --- a/api/v3/server/meters.go +++ b/api/v3/server/meters.go @@ -12,7 +12,7 @@ func (s *Server) ListMeters(w http.ResponseWriter, r *http.Request, params api.L s.meterHandler.ListMeters().With(params).ServeHTTP(w, r) } -func (s *Server) GetMeter(w http.ResponseWriter, r *http.Request, meterId api.ULID) { +func (s *Server) GetMeter(w http.ResponseWriter, r *http.Request, meterIdOrKey api.ULIDOrResourceKey) { apierrors.NewNotImplementedError(r.Context(), errors.New("not implemented")).HandleAPIError(w, r) } From 0dac1fddc25dd93f5f59ed52bee208790525f9d0 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Tue, 9 Dec 2025 00:20:50 +0100 Subject: [PATCH 09/18] feat: request attributes --- api/v3/request/filter.go | 40 +++- api/v3/request/pagination.go | 44 +++- api/v3/request/{ => query}/query.go | 55 ++++- api/v3/request/{ => query}/query_test.go | 40 +++- api/v3/request/request.go | 208 +++++++++++++++-- api/v3/request/request_test.go | 280 +++++++++++++++++++++++ api/v3/request/sort.go | 3 +- 7 files changed, 628 insertions(+), 42 deletions(-) rename api/v3/request/{ => query}/query.go (96%) rename api/v3/request/{ => query}/query_test.go (96%) create mode 100644 api/v3/request/request_test.go diff --git a/api/v3/request/filter.go b/api/v3/request/filter.go index a32eea99f3..ca07cb2ec8 100644 --- a/api/v3/request/filter.go +++ b/api/v3/request/filter.go @@ -1,14 +1,34 @@ package request +import ( + "errors" + + "github.com/samber/lo" +) + type Filter struct { - eq *string `query:"eq"` - neq *string `query:"neq"` - gt *string `query:"gt"` - gte *string `query:"gte"` - lt *string `query:"lt"` - lte *string `query:"lte"` - contains *[]string `query:"contains"` - ocontains *[]string `query:"ocontains"` - exists *bool `query:"exists"` - oeq *string `query:"oeq"` + Eq *string `query:"eq"` + Neq *string `query:"neq"` + Gt *string `query:"gt"` + Gte *string `query:"gte"` + Lt *string `query:"lt"` + Lte *string `query:"lte"` + Contains *[]string `query:"contains"` + OContains *[]string `query:"ocontains"` + Exists *bool `query:"exists"` + OrEq *string `query:"oeq"` +} + +var ErrFilterMultipleFilterOperations = errors.New("only one filter operation is allowed") + +func (f *Filter) Validate() error { + nonNilFilters := lo.CountBy([]bool{ + f.Eq != nil, f.Neq != nil, f.Gt != nil, f.Gte != nil, f.Lt != nil, f.Lte != nil, + f.Contains != nil, f.OContains != nil, f.Exists != nil, f.OrEq != nil, + }, func(b bool) bool { return b }) + if nonNilFilters > 1 { + return ErrFilterMultipleFilterOperations + } + + return nil } diff --git a/api/v3/request/pagination.go b/api/v3/request/pagination.go index b97dac5259..ff4b5524b0 100644 --- a/api/v3/request/pagination.go +++ b/api/v3/request/pagination.go @@ -6,33 +6,55 @@ import ( "github.com/openmeterio/openmeter/pkg/pagination/v2" ) +type paginationKind string + +const ( + paginationKindOffset paginationKind = "offset" + paginationKindCursor paginationKind = "cursor" +) + const ( - DefaultPaginationSize = 20 + DefaultPaginationSize = 20 + DefaultPaginationMaxSize = 100 + DefaultPaginationKind = paginationKindCursor ) var ( - ErrCursorPaginationSizeInvalid = errors.New("size must be greater than 0") - ErrCursorPaginationUndefined = errors.New("at least before or after cursor need to be defined") - ErrCursorPaginationRange = errors.New("range pagination not supported, both before and after cursor were defined") + ErrCursorPaginationSizeInvalid = errors.New("size must be greater than 0") + ErrCursorPaginationRange = errors.New("range pagination not supported, both before and after cursor were defined") + ErrCursorPaginationAfterInvalid = errors.New("after cursor is invalid") + ErrCursorPaginationBeforeInvalid = errors.New("before cursor is invalid") ) -type CursorPagination struct { +type Pagination struct { + kind paginationKind + + // Cursor pagination Size int `query:"size"` After *pagination.Cursor `query:"after"` Before *pagination.Cursor `query:"before"` + + // Offset pagination + Number int `query:"number"` } -func (p *CursorPagination) Validate() error { +func (p *Pagination) Validate() error { if p.Size < 1 { return ErrCursorPaginationSizeInvalid } - if p.After == nil && p.Before == nil { - return ErrCursorPaginationUndefined - } + if p.kind == paginationKindCursor { + if p.After != nil && p.Before != nil { + return ErrCursorPaginationRange + } + + if p.After != nil && p.After.Validate() != nil { + return ErrCursorPaginationAfterInvalid + } - if p.After != nil && p.Before != nil { - return ErrCursorPaginationRange + if p.Before != nil && p.Before.Validate() != nil { + return ErrCursorPaginationBeforeInvalid + } } return nil diff --git a/api/v3/request/query.go b/api/v3/request/query/query.go similarity index 96% rename from api/v3/request/query.go rename to api/v3/request/query/query.go index d0bdb07814..8c34a9c281 100644 --- a/api/v3/request/query.go +++ b/api/v3/request/query/query.go @@ -1,7 +1,8 @@ -package request +package query import ( "context" + "encoding" "errors" "fmt" "net/url" @@ -373,6 +374,17 @@ func Parse(ctx context.Context, str string, opts ...*ParseOptions) (map[string]i } } + if options.Comma { + if s, ok := val.(string); ok && strings.Contains(s, ",") { + parts := strings.Split(s, ",") + vals := make([]interface{}, len(parts)) + for i, p := range parts { + vals[i] = p + } + val = vals + } + } + if err := parseKeys(key, val, options, obj); err != nil { return nil, newQueryAPIError(ctx, err, getCleanKey(key)) } @@ -1180,12 +1192,47 @@ func ruleFromKind(k reflect.Kind) string { } } +var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() + +// tryUnmarshalText attempts to use encoding.TextUnmarshaler on the field when the source is a string. +// It returns true when the field implements the interface and the value was handled. +func tryUnmarshalText(field reflect.Value, text string) (bool, error) { + fieldType := field.Type() + + // Direct implementation (covers pointer-typed fields too) + if fieldType.Implements(textUnmarshalerType) { + if field.Kind() == reflect.Ptr && field.IsNil() { + field.Set(reflect.New(fieldType.Elem())) + } + unmarshaler := field.Interface().(encoding.TextUnmarshaler) + return true, unmarshaler.UnmarshalText([]byte(text)) + } + + // Value types with pointer receivers + if field.CanAddr() { + addrType := field.Addr().Type() + if addrType.Implements(textUnmarshalerType) { + unmarshaler := field.Addr().Interface().(encoding.TextUnmarshaler) + return true, unmarshaler.UnmarshalText([]byte(text)) + } + } + + return false, nil +} + // setFieldValue sets a struct field value from interface{} data func setFieldValue(field reflect.Value, value interface{}) error { if value == nil { return nil } + // Prefer TextUnmarshaler when the input is a string + if str, ok := value.(string); ok { + if handled, err := tryUnmarshalText(field, str); handled { + return err + } + } + fieldType := field.Type() valueReflect := reflect.ValueOf(value) @@ -1202,6 +1249,12 @@ func setFieldValue(field reflect.Value, value interface{}) error { case reflect.String: if str, ok := value.(string); ok { field.SetString(str) + } else if slice, ok := value.([]interface{}); ok { + parts := make([]string, 0, len(slice)) + for _, item := range slice { + parts = append(parts, fmt.Sprint(item)) + } + field.SetString(strings.Join(parts, ",")) } else { field.SetString(fmt.Sprintf("%v", value)) } diff --git a/api/v3/request/query_test.go b/api/v3/request/query/query_test.go similarity index 96% rename from api/v3/request/query_test.go rename to api/v3/request/query/query_test.go index 9454670fd7..a89567617b 100644 --- a/api/v3/request/query_test.go +++ b/api/v3/request/query/query_test.go @@ -1,7 +1,8 @@ -package request +package query import ( "context" + "errors" "reflect" "strings" "testing" @@ -409,6 +410,27 @@ type Product struct { Variants []ProductVariant `query:"variants"` } +type TextUnmarshalStruct struct { + Value string +} + +func (t *TextUnmarshalStruct) UnmarshalText(text []byte) error { + if len(text) == 0 { + return errors.New("empty text") + } + + t.Value = strings.ToUpper(string(text)) + return nil +} + +type WrapperWithTextUnmarshaler struct { + Custom TextUnmarshalStruct `query:"custom"` +} + +type WrapperWithPointerTextUnmarshaler struct { + Custom *TextUnmarshalStruct `query:"custom"` +} + func TestParseToStruct(t *testing.T) { tests := []struct { name string @@ -460,6 +482,22 @@ func TestParseToStruct(t *testing.T) { Enabled: true, }, }, + { + name: "struct field using UnmarshalText", + input: "custom=hello world", + dest: &WrapperWithTextUnmarshaler{}, + expected: &WrapperWithTextUnmarshaler{ + Custom: TextUnmarshalStruct{Value: "HELLO WORLD"}, + }, + }, + { + name: "pointer field using UnmarshalText", + input: "custom=go test", + dest: &WrapperWithPointerTextUnmarshaler{}, + expected: &WrapperWithPointerTextUnmarshaler{ + Custom: &TextUnmarshalStruct{Value: "GO TEST"}, + }, + }, } for _, tt := range tests { diff --git a/api/v3/request/request.go b/api/v3/request/request.go index 7d8610d3c0..7253a85f33 100644 --- a/api/v3/request/request.go +++ b/api/v3/request/request.go @@ -1,42 +1,214 @@ package request -import "net/http" +import ( + "errors" + "fmt" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request/query" +) + +type config struct { + defaultPageSize int + maxPageSize int + paginationKind paginationKind + defaultSort *SortBy + parseOptions *query.ParseOptions +} + +func newConfig() *config { + return &config{ + paginationKind: paginationKindOffset, + maxPageSize: DefaultPaginationMaxSize, + defaultPageSize: DefaultPaginationSize, + parseOptions: &query.ParseOptions{ + Comma: true, + }, + } +} type QueryAttributes struct { - Pagination CursorPagination - Filters []Filter - Sorts []SortBy + Pagination Pagination `query:"page"` + Filters map[string]Filter `query:"filter"` + Sorts []SortBy `query:"sort"` } -// GetAttributes return the Attributes found in the request query string -func GetAttributes(r *http.Request) (*QueryAttributes, error) { - a := &AipAttributes{} +type AttributesOption func(*config) + +// WithCursorPagination sets the attributes parser to only take the cursor +// attributes in consideration and will ignore other kinds of paginations. +// +// This is the default behavior. +func WithCursorPagination() AttributesOption { + return func(c *config) { + c.paginationKind = paginationKindCursor + } +} +// WithCursorPagination sets the request parser to only take the offset +// attributes in consideration and will ignore other kinds of paginations. +func WithOffsetPagination() AttributesOption { + return func(c *config) { + c.paginationKind = paginationKindOffset + } +} + +// WithDefaultPageSizeDefault sets the request parser default page size. +// This value is used when the client is not setting the page[size] querystring +// or when the page[size] attribute is not valid. +// +// Default value is 20 +func WithDefaultPageSizeDefault(value int) AttributesOption { + return func(c *config) { + c.defaultPageSize = value + } +} + +// WithDefaultSort sets the default sort order for the attributes parser. +// This value is used when the client is not setting the sort querystring +// or when the sort attribute is not valid. +// +// Default value is nil +func WithDefaultSort(sort *SortBy) AttributesOption { + return func(c *config) { + c.defaultSort = sort + } +} + +// WithQueryParseOptions overrides the default query parse options used when +// parsing request attributes. +func WithQueryParseOptions(opts *query.ParseOptions) AttributesOption { + return func(c *config) { + c.parseOptions = opts + } +} + +// GetAttributes return the Attributes found in the request query string +func GetAttributes(r *http.Request, opts ...AttributesOption) (*QueryAttributes, error) { conf := newConfig() for _, v := range opts { v(conf) } - queryValues := r.URL.Query() + a := &QueryAttributes{ + Pagination: Pagination{ + kind: conf.paginationKind, + Size: conf.defaultPageSize, + }, + } - pagination, err := extractPagination(r.Context(), queryValues, conf) + err := query.ParseToStruct(r.Context(), r.URL.RawQuery, a, conf.parseOptions) if err != nil { return nil, err } - a.Pagination = pagination - filters, err := extractFilter(r.Context(), queryValues, conf) - if err != nil { - return nil, err + if err := a.Pagination.Validate(); err != nil { + if errors.Is(err, ErrCursorPaginationSizeInvalid) { + return nil, apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page[size]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }, + ) + } + + if errors.Is(err, ErrCursorPaginationRange) { + return nil, apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page[after]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + apierrors.InvalidParameter{ + Field: "page[before]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }, + ) + } + + if errors.Is(err, ErrCursorPaginationAfterInvalid) { + return nil, apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page[after]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }, + ) + } + + if errors.Is(err, ErrCursorPaginationBeforeInvalid) { + return nil, apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page[before]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }, + ) + } + + return nil, apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page", + Reason: "unable to parse query field", + Source: apierrors.InvalidParamSourceQuery, + }, + }, + ) } - a.Filters = filters - sort, err := extractSort(queryValues, conf) - if err != nil { - return nil, err + if a.Pagination.Size > conf.maxPageSize { + return nil, apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page[size]", + Reason: fmt.Sprintf("page size must be less than or equal to %d", conf.maxPageSize), + Source: apierrors.InvalidParamSourceQuery, + }, + }, + ) + } + + for _, sort := range a.Sorts { + if err := sort.Validate(); err != nil { + if errors.Is(err, ErrSortFieldRequired) || errors.Is(err, ErrSortOrderInvalid) || errors.Is(err, ErrSortByInvalid) { + return nil, apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "sort", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }, + ) + } + } } - a.Sorts = sort + for key, filter := range a.Filters { + if err := filter.Validate(); err != nil { + return nil, apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: fmt.Sprintf("filter[%s]", key), + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }, + ) + } + } return a, nil } diff --git a/api/v3/request/request_test.go b/api/v3/request/request_test.go new file mode 100644 index 0000000000..04c20e6ad5 --- /dev/null +++ b/api/v3/request/request_test.go @@ -0,0 +1,280 @@ +package request + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/openmeterio/openmeter/pkg/pagination/v2" + "github.com/samber/lo" + "github.com/stretchr/testify/require" +) + +func TestGetAttributes(t *testing.T) { + beforeCursor := pagination.NewCursor(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), "before-id") + afterCursor := pagination.NewCursor(time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), "after-id") + + tests := []struct { + name string + input string + opts []AttributesOption + want *QueryAttributes + wantErr bool + }{ + // offset pagination + { + name: "offset pagination default", + input: "", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: DefaultPaginationSize, + }, + }, + }, + { + name: "offset pagination with size and number", + opts: []AttributesOption{WithOffsetPagination()}, + input: "page[size]=10&page[number]=3", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: 10, + Number: 3, + }, + }, + }, + // cursor pagination + { + name: "cursor pagination with size", + opts: []AttributesOption{WithCursorPagination()}, + input: "page[size]=10", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindCursor, + Size: 10, + }, + }, + }, + { + name: "cursor pagination before", + opts: []AttributesOption{WithCursorPagination()}, + input: fmt.Sprintf("page[size]=5&page[before]=%s", beforeCursor.Encode()), + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindCursor, + Size: 5, + Before: &beforeCursor, + }, + }, + }, + { + name: "cursor pagination after", + opts: []AttributesOption{WithCursorPagination()}, + input: fmt.Sprintf("page[size]=7&page[after]=%s", afterCursor.Encode()), + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindCursor, + Size: 7, + After: &afterCursor, + }, + }, + }, + { + name: "invalid query", + opts: []AttributesOption{WithCursorPagination()}, + input: "page[size]=lookatmyhorse", + wantErr: true, + }, + { + name: "cursor pagination range not supported", + opts: []AttributesOption{WithCursorPagination()}, + input: fmt.Sprintf("page[size]=5&page[before]=%s&page[after]=%s", beforeCursor.Encode(), afterCursor.Encode()), + wantErr: true, + }, + { + name: "page size above maximum", + input: "page[size]=200", + wantErr: true, + }, + // sort by + { + name: "single sort default order", + input: "sort=id", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: DefaultPaginationSize, + }, + Sorts: []SortBy{ + { + Field: "id", + Order: SortOrderAsc, + }, + }, + }, + }, + { + name: "single sort desc order", + input: "sort=id desc", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: DefaultPaginationSize, + }, + Sorts: []SortBy{ + { + Field: "id", + Order: SortOrderDesc, + }, + }, + }, + }, + { + name: "invalid sort order", + input: "sort=id invalid", + wantErr: true, + }, + // filters + { + name: "single filter equals", + input: "filter[id][eq]=123", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: DefaultPaginationSize, + }, + Filters: map[string]Filter{ + "id": { + Eq: lo.ToPtr("123"), + }, + }, + }, + }, + { + name: "single filter not equals", + input: "filter[id][neq]=123", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: DefaultPaginationSize, + }, + Filters: map[string]Filter{ + "id": { + Neq: lo.ToPtr("123"), + }, + }, + }, + }, + { + name: "multiple filters", + input: "filter[a][gt]=10&filter[b][gte]=11&filter[c][contains]=foo,bar", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: DefaultPaginationSize, + }, + Filters: map[string]Filter{ + "a": { + Gt: lo.ToPtr("10"), + }, + "b": { + Gte: lo.ToPtr("11"), + }, + "c": { + Contains: lo.ToPtr([]string{"foo", "bar"}), + }, + }, + }, + }, + { + name: "exists filter", + input: "filter[active][exists]=true", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: DefaultPaginationSize, + }, + Filters: map[string]Filter{ + "active": { + Exists: lo.ToPtr(true), + }, + }, + }, + }, + { + name: "or equals filter", + input: "filter[id][oeq]=foo,bar", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: DefaultPaginationSize, + }, + Filters: map[string]Filter{ + "id": { + OrEq: lo.ToPtr("foo,bar"), + }, + }, + }, + }, + // pagination, sort by, filters + { + name: "pagination sort and filter combined", + input: "page[size]=5&page[number]=2&sort=id desc&filter[name][eq]=kong", + opts: []AttributesOption{WithOffsetPagination()}, + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindOffset, + Size: 5, + Number: 2, + }, + Filters: map[string]Filter{ + "name": { + Eq: lo.ToPtr("kong"), + }, + }, + Sorts: []SortBy{ + { + Field: "id", + Order: SortOrderDesc, + }, + }, + }, + }, + // edge cases + { + name: "unrelated query fields", + opts: []AttributesOption{WithCursorPagination()}, + input: "page[size]=10&foo=1&sort=id", + want: &QueryAttributes{ + Pagination: Pagination{ + kind: paginationKindCursor, + Size: 10, + }, + Sorts: []SortBy{ + { + Field: "id", + Order: SortOrderAsc, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:8080?%s", tt.input), nil) + require.NoError(t, err) + + attributes, err := GetAttributes(req, tt.opts...) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, attributes) + }) + } +} diff --git a/api/v3/request/sort.go b/api/v3/request/sort.go index 38f7b85981..f3b51d1d3a 100644 --- a/api/v3/request/sort.go +++ b/api/v3/request/sort.go @@ -14,7 +14,7 @@ const ( var ( ErrSortByInvalid = errors.New("invalid sort by") - ErrSortFieldRequired = errors.New("field is required") + ErrSortFieldRequired = errors.New("sort field is required") ErrSortOrderInvalid = errors.New("sort order must be either asc or desc") defaultOrder = SortOrderAsc ) @@ -23,6 +23,7 @@ func (s SortOrder) Validate() error { if s != SortOrderAsc && s != SortOrderDesc { return ErrSortOrderInvalid } + return nil } From 7a57fbdce0464e23f55ad31ab36446ff50241f32 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Tue, 9 Dec 2025 11:01:10 +0100 Subject: [PATCH 10/18] feat: attributes mapping --- api/v3/handlers/customer.go | 34 ++++- api/v3/request/filter.go | 60 ++++++-- api/v3/request/query/query_test.go | 158 ++++++++-------------- api/v3/request/request.go | 6 +- api/v3/request/request_test.go | 6 +- api/v3/request/sort.go | 10 ++ openmeter/customer/adapter/customer.go | 7 +- openmeter/customer/customer.go | 3 +- openmeter/customer/httpdriver/customer.go | 2 +- 9 files changed, 163 insertions(+), 123 deletions(-) diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index ef1bbd04fe..d0b0c1674f 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -13,6 +13,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" ) type CustomerHandler interface { @@ -56,10 +57,41 @@ func (h *customerHandler) ListCustomers() ListCustomersHandler { return ListCustomersRequest{}, err } + attributes, err := request.GetAttributes(r, + request.WithOffsetPagination(), + request.WithDefaultSort(&request.SortBy{Field: "name", Order: request.SortOrderAsc}), + ) + if err != nil { + return ListCustomersRequest{}, err + } + req := ListCustomersRequest{ Namespace: ns, + Page: pagination.NewPage(attributes.Pagination.Number, attributes.Pagination.Size), + } + + // Pick the first sort if there are multiple + if len(attributes.Sorts) > 0 { + req.OrderBy = attributes.Sorts[0].Field + req.Order = attributes.Sorts[0].Order.ToSortxOrder() + } - // TODO cursor pagination + // Filters + if attributes.Filters != nil { + for field, f := range attributes.Filters { + switch field { + case "key": + req.Key = f.ToFilterString() + case "name": + req.Name = f.ToFilterString() + case "primary_email": + req.PrimaryEmail = f.ToFilterString() + case "subject": + req.Subject = f.ToFilterString() + case "customer_ids": + req.CustomerIDs = f.ToFilterString() + } + } } return req, nil diff --git a/api/v3/request/filter.go b/api/v3/request/filter.go index ca07cb2ec8..cd3708b562 100644 --- a/api/v3/request/filter.go +++ b/api/v3/request/filter.go @@ -3,20 +3,31 @@ package request import ( "errors" + "github.com/openmeterio/openmeter/pkg/filter" "github.com/samber/lo" ) type Filter struct { - Eq *string `query:"eq"` - Neq *string `query:"neq"` - Gt *string `query:"gt"` - Gte *string `query:"gte"` - Lt *string `query:"lt"` - Lte *string `query:"lte"` - Contains *[]string `query:"contains"` - OContains *[]string `query:"ocontains"` - Exists *bool `query:"exists"` - OrEq *string `query:"oeq"` + // Equals + Exists *bool `query:"exists"` + // Equals operator + Eq *string `query:"eq"` + // Not equals operator + Neq *string `query:"neq"` + // Greater than operator + Gt *string `query:"gt"` + // Greater than or equal to operator + Gte *string `query:"gte"` + // Less than operator + Lt *string `query:"lt"` + // Less than or equal to operator + Lte *string `query:"lte"` + // Contains operator + Contains *string `query:"contains"` + // Or contains operator (in like) + OrContains *[]string `query:"ocontains"` + // Or equals operator (in) + OrEq *[]string `query:"oeq"` } var ErrFilterMultipleFilterOperations = errors.New("only one filter operation is allowed") @@ -24,7 +35,7 @@ var ErrFilterMultipleFilterOperations = errors.New("only one filter operation is func (f *Filter) Validate() error { nonNilFilters := lo.CountBy([]bool{ f.Eq != nil, f.Neq != nil, f.Gt != nil, f.Gte != nil, f.Lt != nil, f.Lte != nil, - f.Contains != nil, f.OContains != nil, f.Exists != nil, f.OrEq != nil, + f.Contains != nil, f.OrContains != nil, f.Exists != nil, f.OrEq != nil, }, func(b bool) bool { return b }) if nonNilFilters > 1 { return ErrFilterMultipleFilterOperations @@ -32,3 +43,30 @@ func (f *Filter) Validate() error { return nil } + +func (f *Filter) ToFilterString() *filter.FilterString { + fs := &filter.FilterString{ + Eq: f.Eq, + Ne: f.Neq, + Gt: f.Gt, + Gte: f.Gte, + Lt: f.Lt, + Lte: f.Lte, + In: f.OrEq, + Ilike: f.Contains, + } + + if f.Exists != nil { + fs.Ne = lo.ToPtr("") + } + + if f.OrContains != nil { + fs.Or = lo.ToPtr(lo.Map(*f.OrContains, func(s string, _ int) filter.FilterString { + return filter.FilterString{ + Ilike: &s, + } + })) + } + + return fs +} diff --git a/api/v3/request/query/query_test.go b/api/v3/request/query/query_test.go index a89567617b..5620e7dbc1 100644 --- a/api/v3/request/query/query_test.go +++ b/api/v3/request/query/query_test.go @@ -6,6 +6,8 @@ import ( "reflect" "strings" "testing" + + "github.com/stretchr/testify/require" ) func TestParse(t *testing.T) { @@ -54,13 +56,12 @@ func TestParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Parse(t.Context(), tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + require.NotNil(t, err) return } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Parse() = %v, want %v", got, tt.want) - } + require.Nil(t, err) + require.Equal(t, tt.want, got) }) } } @@ -185,13 +186,12 @@ func TestParseComplex(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Parse(t.Context(), tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + require.NotNil(t, err) return } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Parse() = %v, want %v", got, tt.want) - } + require.Nil(t, err) + require.Equal(t, tt.want, got) }) } } @@ -202,9 +202,7 @@ func TestParseWithOptions(t *testing.T) { result, err := Parse(ctx, "name=John;age=30;city=NYC", &ParseOptions{ Delimiter: ";", }) - if err != nil { - t.Errorf("Parse with custom delimiter failed: %v", err) - } + require.Nil(t, err) expected := map[string]interface{}{ "name": "John", @@ -212,9 +210,7 @@ func TestParseWithOptions(t *testing.T) { "city": "NYC", } - if !reflect.DeepEqual(result, expected) { - t.Errorf("Parse with custom delimiter = %v, want %v", result, expected) - } + require.Equal(t, expected, result) // Test with parameter limit longQuery := strings.Repeat("param=value&", 1001) @@ -224,21 +220,15 @@ func TestParseWithOptions(t *testing.T) { ParameterLimit: 100, ThrowOnLimitExceeded: true, }) - if err == nil { - t.Error("Expected error when exceeding parameter limit") - } + require.NotNil(t, err) // Test without throwing on limit exceeded result2, err := Parse(ctx, longQuery, &ParseOptions{ ParameterLimit: 100, ThrowOnLimitExceeded: false, }) - if err != nil { - t.Errorf("Parse without throwing on limit exceeded failed: %v", err) - } - if len(result2) != 1 { // Should only have one key "param" with last value - t.Errorf("Expected 1 key in result, got %d", len(result2)) - } + require.Nil(t, err) + require.Len(t, result2, 1) // Should only have one key "param" with last value } func TestStringify(t *testing.T) { @@ -277,18 +267,15 @@ func TestStringify(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Stringify(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("Stringify() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + require.Error(t, err) return } + require.NoError(t, err) // Since order is not guaranteed in maps, we check if all expected parts are present - if !tt.wantErr { - parts := strings.Split(tt.want, "&") - for _, part := range parts { - if !strings.Contains(got, part) { - t.Errorf("Stringify() = %v, should contain %v", got, part) - } - } + parts := strings.Split(tt.want, "&") + for _, part := range parts { + require.Contains(t, got, part) } }) } @@ -330,16 +317,13 @@ func TestStringifyComplex(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Stringify(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("Stringify() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + require.Error(t, err) return } - if !tt.wantErr { - for _, want := range tt.want { - if !strings.Contains(got, want) { - t.Errorf("Stringify() = %v, should contain %v", got, want) - } - } + require.NoError(t, err) + for _, want := range tt.want { + require.Contains(t, got, want) } }) } @@ -355,23 +339,15 @@ func TestStringifyWithOptionsSimple(t *testing.T) { result, err := Stringify(input, &StringifyOptions{ AddQueryPrefix: true, }) - if err != nil { - t.Errorf("Stringify with AddQueryPrefix failed: %v", err) - } - if !strings.HasPrefix(result, "?") { - t.Errorf("Expected result to start with '?', got %s", result) - } + require.NoError(t, err) + require.True(t, strings.HasPrefix(result, "?")) // Test with custom delimiter result2, err := Stringify(input, &StringifyOptions{ Delimiter: ";", }) - if err != nil { - t.Errorf("Stringify with custom delimiter failed: %v", err) - } - if !strings.Contains(result2, ";") { - t.Errorf("Expected result to contain ';', got %s", result2) - } + require.NoError(t, err) + require.Contains(t, result2, ";") } // Test structures for struct parsing @@ -503,13 +479,12 @@ func TestParseToStruct(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ParseToStruct(t.Context(), tt.input, tt.dest) - if (err != nil) != tt.wantErr { - t.Errorf("ParseToStruct() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + require.NotNil(t, err) return } - if !tt.wantErr && !reflect.DeepEqual(tt.dest, tt.expected) { - t.Errorf("ParseToStruct() = %+v, want %+v", tt.dest, tt.expected) - } + require.Nil(t, err) + require.Equal(t, tt.expected, tt.dest) }) } } @@ -551,16 +526,13 @@ func TestStructToQueryString(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := StructToQueryString(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("StructToQueryString() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + require.Error(t, err) return } - if !tt.wantErr { - for _, substr := range tt.contains { - if !strings.Contains(got, substr) { - t.Errorf("StructToQueryString() = %v, expected to contain %v", got, substr) - } - } + require.NoError(t, err) + for _, substr := range tt.contains { + require.Contains(t, got, substr) } }) } @@ -577,9 +549,7 @@ func TestMapToStruct(t *testing.T) { var user User err := MapToStruct(t.Context(), data, &user) - if err != nil { - t.Fatalf("MapToStruct() error = %v", err) - } + require.Nil(t, err) expected := User{ Name: "Bob", @@ -589,9 +559,7 @@ func TestMapToStruct(t *testing.T) { Score: 92.3, } - if !reflect.DeepEqual(user, expected) { - t.Errorf("MapToStruct() = %+v, want %+v", user, expected) - } + require.Equal(t, expected, user) } func TestStructToMap(t *testing.T) { @@ -604,9 +572,7 @@ func TestStructToMap(t *testing.T) { } got, err := StructToMap(user) - if err != nil { - t.Fatalf("StructToMap() error = %v", err) - } + require.NoError(t, err) expected := map[string]interface{}{ "name": "Charlie", @@ -616,9 +582,7 @@ func TestStructToMap(t *testing.T) { "score": 78.9, } - if !reflect.DeepEqual(got, expected) { - t.Errorf("StructToMap() = %+v, want %+v", got, expected) - } + require.Equal(t, expected, got) } func BenchmarkParseSimple(b *testing.B) { @@ -791,13 +755,12 @@ func TestUnmarshal(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := Unmarshal(t.Context(), tt.query, tt.target) - if (err != nil) != tt.wantErr { - t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + require.NotNil(t, err) return } - if !tt.wantErr && !reflect.DeepEqual(tt.target, tt.expected) { - t.Errorf("Unmarshal() = %+v, want %+v", tt.target, tt.expected) - } + require.Nil(t, err) + require.Equal(t, tt.expected, tt.target) }) } } @@ -860,16 +823,13 @@ func TestMarshal(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Marshal(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("Marshal() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + require.Error(t, err) return } - if !tt.wantErr { - for _, substr := range tt.contains { - if !strings.Contains(got, substr) { - t.Errorf("Marshal() = %v, expected to contain %v", got, substr) - } - } + require.NoError(t, err) + for _, substr := range tt.contains { + require.Contains(t, got, substr) } }) } @@ -924,9 +884,7 @@ func TestMarshalUnmarshalRoundTrip(t *testing.T) { ctx := t.Context() // Marshal to query string queryString, err := Marshal(tt.input) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } + require.NoError(t, err) // Create a new instance of the same type targetType := reflect.TypeOf(tt.input) @@ -939,9 +897,7 @@ func TestMarshalUnmarshalRoundTrip(t *testing.T) { // Compare (dereference pointer) targetValue := reflect.ValueOf(target).Elem().Interface() - if !reflect.DeepEqual(tt.input, targetValue) { - t.Errorf("Round trip failed: original = %+v, result = %+v", tt.input, targetValue) - } + require.Equal(t, tt.input, targetValue) }) } } @@ -976,9 +932,11 @@ func TestUnmarshalErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := Unmarshal(t.Context(), tt.query, tt.target) - if (err != nil) != tt.wantErr { - t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + require.NotNil(t, err) + return } + require.Nil(t, err) }) } } diff --git a/api/v3/request/request.go b/api/v3/request/request.go index 7253a85f33..3e67e26e80 100644 --- a/api/v3/request/request.go +++ b/api/v3/request/request.go @@ -98,7 +98,11 @@ func GetAttributes(r *http.Request, opts ...AttributesOption) (*QueryAttributes, }, } - err := query.ParseToStruct(r.Context(), r.URL.RawQuery, a, conf.parseOptions) + if conf.defaultSort != nil { + a.Sorts = []SortBy{*conf.defaultSort} + } + + err := query.Unmarshal(r.Context(), r.URL.RawQuery, a, conf.parseOptions) if err != nil { return nil, err } diff --git a/api/v3/request/request_test.go b/api/v3/request/request_test.go index 04c20e6ad5..2e98a67d82 100644 --- a/api/v3/request/request_test.go +++ b/api/v3/request/request_test.go @@ -169,7 +169,7 @@ func TestGetAttributes(t *testing.T) { }, { name: "multiple filters", - input: "filter[a][gt]=10&filter[b][gte]=11&filter[c][contains]=foo,bar", + input: "filter[a][gt]=10&filter[b][gte]=11&filter[c][oeq]=foo,bar", want: &QueryAttributes{ Pagination: Pagination{ kind: paginationKindOffset, @@ -183,7 +183,7 @@ func TestGetAttributes(t *testing.T) { Gte: lo.ToPtr("11"), }, "c": { - Contains: lo.ToPtr([]string{"foo", "bar"}), + OrEq: lo.ToPtr([]string{"foo", "bar"}), }, }, }, @@ -213,7 +213,7 @@ func TestGetAttributes(t *testing.T) { }, Filters: map[string]Filter{ "id": { - OrEq: lo.ToPtr("foo,bar"), + OrEq: lo.ToPtr([]string{"foo", "bar"}), }, }, }, diff --git a/api/v3/request/sort.go b/api/v3/request/sort.go index f3b51d1d3a..d0dbd45d1b 100644 --- a/api/v3/request/sort.go +++ b/api/v3/request/sort.go @@ -3,6 +3,8 @@ package request import ( "errors" "strings" + + "github.com/openmeterio/openmeter/pkg/sortx" ) type SortOrder string @@ -59,3 +61,11 @@ func (s *SortBy) UnmarshalText(text []byte) error { return s.Validate() } + +func (s SortOrder) ToSortxOrder() sortx.Order { + if s == SortOrderAsc { + return sortx.OrderAsc + } + + return sortx.OrderDesc +} diff --git a/openmeter/customer/adapter/customer.go b/openmeter/customer/adapter/customer.go index 8dfba83dfe..c3556b9527 100644 --- a/openmeter/customer/adapter/customer.go +++ b/openmeter/customer/adapter/customer.go @@ -9,7 +9,6 @@ import ( "entgo.io/ent/dialect/sql" "github.com/samber/lo" - "github.com/openmeterio/openmeter/api" "github.com/openmeterio/openmeter/openmeter/customer" entdb "github.com/openmeterio/openmeter/openmeter/ent/db" customerdb "github.com/openmeterio/openmeter/openmeter/ent/db/customer" @@ -88,11 +87,11 @@ func (a *adapter) ListCustomers(ctx context.Context, input customer.ListCustomer } switch input.OrderBy { - case api.CustomerOrderById: + case "id": query = query.Order(customerdb.ByID(order...)) - case api.CustomerOrderByCreatedAt: + case "created_at": query = query.Order(customerdb.ByCreatedAt(order...)) - case api.CustomerOrderByName: + case "name": fallthrough default: query = query.Order(customerdb.ByName(order...)) diff --git a/openmeter/customer/customer.go b/openmeter/customer/customer.go index fa665c5670..e96756bc80 100644 --- a/openmeter/customer/customer.go +++ b/openmeter/customer/customer.go @@ -7,7 +7,6 @@ import ( "github.com/samber/mo" - "github.com/openmeterio/openmeter/api" "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/currencyx" @@ -265,7 +264,7 @@ type ListCustomersInput struct { IncludeDeleted bool // Order - OrderBy api.CustomerOrderBy + OrderBy string Order sortx.Order // Filters diff --git a/openmeter/customer/httpdriver/customer.go b/openmeter/customer/httpdriver/customer.go index a83118863b..337967665b 100644 --- a/openmeter/customer/httpdriver/customer.go +++ b/openmeter/customer/httpdriver/customer.go @@ -49,7 +49,7 @@ func (h *handler) ListCustomers() ListCustomersHandler { }, // Order - OrderBy: defaultx.WithDefault(params.OrderBy, api.CustomerOrderByName), + OrderBy: string(defaultx.WithDefault(params.OrderBy, api.CustomerOrderByName)), Order: sortx.Order(defaultx.WithDefault(params.Order, api.SortOrderASC)), // Modifiers From dd58416688b82e8c6f3196b573983e67dc361d37 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:01:37 +0100 Subject: [PATCH 11/18] feat: filter optional --- api/spec/src/v3/customers/operations.tsp | 18 +- api/v3/api.gen.go | 246 +++++++++++------------ api/v3/openapi.yaml | 10 - 3 files changed, 132 insertions(+), 142 deletions(-) diff --git a/api/spec/src/v3/customers/operations.tsp b/api/spec/src/v3/customers/operations.tsp index 9867d0b87e..bc2f7f84c9 100644 --- a/api/spec/src/v3/customers/operations.tsp +++ b/api/spec/src/v3/customers/operations.tsp @@ -39,16 +39,16 @@ model ListCustomersParams { /** * Filter customers returned in the response. */ - @query(#{ style: "deepObject" }) + @query(#{ style: "deepObject", explode: true }) filter?: { - id: Common.StringFieldFilter; - key: Common.StringFieldFilter; - name: Common.StringFieldFilter; - `usage_attribution.subject_keys`: Common.StringFieldFilter; - primary_email: Common.StringFieldFilter; - created_at: Common.DateTimeFieldFilter; - updated_at: Common.DateTimeFieldFilter; - deleted_at: Common.DateTimeFieldFilter; + id?: Common.StringFieldFilter; + key?: Common.StringFieldFilter; + name?: Common.StringFieldFilter; + `usage_attribution.subject_keys`?: Common.StringFieldFilter; + primary_email?: Common.StringFieldFilter; + created_at?: Common.DateTimeFieldFilter; + updated_at?: Common.DateTimeFieldFilter; + deleted_at?: Common.DateTimeFieldFilter; }; } diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index 80b074e54e..ad7a4dffde 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -731,28 +731,28 @@ type CursorPageQuery = CursorPageParameters // ListCustomersParamsFilter defines model for ListCustomersParams.filter. type ListCustomersParamsFilter struct { // CreatedAt Filters on the given datetime (RFC-3339) field value. - CreatedAt DateTimeFieldFilter `json:"created_at"` + CreatedAt *DateTimeFieldFilter `json:"created_at,omitempty"` // DeletedAt Filters on the given datetime (RFC-3339) field value. - DeletedAt DateTimeFieldFilter `json:"deleted_at"` + DeletedAt *DateTimeFieldFilter `json:"deleted_at,omitempty"` // Id Filters on the given string field value by either exact or fuzzy match. - Id StringFieldFilter `json:"id"` + Id *StringFieldFilter `json:"id,omitempty"` // Key Filters on the given string field value by either exact or fuzzy match. - Key StringFieldFilter `json:"key"` + Key *StringFieldFilter `json:"key,omitempty"` // Name Filters on the given string field value by either exact or fuzzy match. - Name StringFieldFilter `json:"name"` + Name *StringFieldFilter `json:"name,omitempty"` // PrimaryEmail Filters on the given string field value by either exact or fuzzy match. - PrimaryEmail StringFieldFilter `json:"primary_email"` + PrimaryEmail *StringFieldFilter `json:"primary_email,omitempty"` // UpdatedAt Filters on the given datetime (RFC-3339) field value. - UpdatedAt DateTimeFieldFilter `json:"updated_at"` + UpdatedAt *DateTimeFieldFilter `json:"updated_at,omitempty"` // UsageAttributionSubjectKeys Filters on the given string field value by either exact or fuzzy match. - UsageAttributionSubjectKeys StringFieldFilter `json:"usage_attribution.subject_keys"` + UsageAttributionSubjectKeys *StringFieldFilter `json:"usage_attribution.subject_keys,omitempty"` } // ListCustomersParamsSort The `asc` suffix is optional as the default sort order is ascending. @@ -1544,7 +1544,7 @@ func (siw *ServerInterfaceWrapper) ListCustomers(w http.ResponseWriter, r *http. // ------------- Optional query parameter "filter" ------------- - err = runtime.BindQueryParameter("deepObject", false, false, "filter", r.URL.Query(), ¶ms.Filter) + err = runtime.BindQueryParameter("deepObject", true, false, "filter", r.URL.Query(), ¶ms.Filter) if err != nil { siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter", Err: err}) return @@ -1905,120 +1905,120 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "5M+QpyGvz9b/PwAA//8AhsIQgpYAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index aa472dfe32..b44f547a99 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -373,16 +373,6 @@ components: $ref: '#/components/schemas/DateTimeFieldFilter' deleted_at: $ref: '#/components/schemas/DateTimeFieldFilter' - required: - - id - - key - - name - - usage_attribution.subject_keys - - primary_email - - created_at - - updated_at - - deleted_at - explode: false style: deepObject ListCustomersParams.sort: name: sort From 817dff588dcc14a431a0683564298337e8d62bc0 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:25:40 +0100 Subject: [PATCH 12/18] chore: WIP --- api/v3/api.gen.go | 322 +++++++++-------------- api/v3/codegen.yaml | 5 + api/v3/handlers/customer.go | 7 +- api/v3/handlers/meters.go | 8 +- api/v3/overlay.yaml | 15 ++ api/v3/request/query/query.go | 2 +- api/v3/request/query/query_test.go | 2 +- api/v3/server/customers.go | 4 +- api/v3/server/meters.go | 4 +- api/v3/server/server.go | 8 +- api/v3/templates/chi/chi-middleware.tmpl | 266 +++++++++++++++++++ 11 files changed, 421 insertions(+), 222 deletions(-) create mode 100644 api/v3/overlay.yaml create mode 100644 api/v3/templates/chi/chi-middleware.tmpl diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index ad7a4dffde..6b85f09864 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -779,30 +779,6 @@ type NotFound = NotFoundError // Unauthorized defines model for Unauthorized. type Unauthorized = UnauthorizedError -// ListCustomersParams defines parameters for ListCustomers. -type ListCustomersParams struct { - // Page Determines which page of the collection to retrieve. - Page *CursorPageQuery `form:"page,omitempty" json:"page,omitempty"` - - // Sort Sort customers returned in the response. - // Supported sort attributes are: - // - `key` - // - `id` - // - `name` - // - `primary_email` - // - `created_at` (default) - // - `updated_at` - // - `deleted_at` - // - // The `asc` suffix is optional as the default sort order is ascending. - // The `desc` suffix is used to specify a descending order. - // Multiple sort attributes may be provided via a comma separated list. - Sort *ListCustomersParamsSort `form:"sort,omitempty" json:"sort,omitempty"` - - // Filter Filter customers returned in the response. - Filter *ListCustomersParamsFilter `json:"filter,omitempty"` -} - // IngestMeteringEventsApplicationCloudeventsBatchPlusJSONBody defines parameters for IngestMeteringEvents. type IngestMeteringEventsApplicationCloudeventsBatchPlusJSONBody = []MeteringEvent @@ -814,12 +790,6 @@ type IngestMeteringEventsJSONBody struct { // IngestMeteringEventsJSONBody1 defines parameters for IngestMeteringEvents. type IngestMeteringEventsJSONBody1 = []MeteringEvent -// ListMetersParams defines parameters for ListMeters. -type ListMetersParams struct { - // Page Determines which page of the collection to retrieve. - Page *CursorPageQuery `form:"page,omitempty" json:"page,omitempty"` -} - // CreateCustomerJSONRequestBody defines body for CreateCustomer for application/json ContentType. type CreateCustomerJSONRequestBody = CreateCustomerRequest @@ -1415,7 +1385,7 @@ func (t *ULIDOrResourceKey) UnmarshalJSON(b []byte) error { type ServerInterface interface { // List customers // (GET /openmeter/customers) - ListCustomers(w http.ResponseWriter, r *http.Request, params ListCustomersParams) + ListCustomers(w http.ResponseWriter, r *http.Request) // Create customer // (POST /openmeter/customers) CreateCustomer(w http.ResponseWriter, r *http.Request) @@ -1436,7 +1406,7 @@ type ServerInterface interface { IngestMeteringEvents(w http.ResponseWriter, r *http.Request) // List meters // (GET /openmeter/meters) - ListMeters(w http.ResponseWriter, r *http.Request, params ListMetersParams) + ListMeters(w http.ResponseWriter, r *http.Request) // Create meter // (POST /openmeter/meters) CreateMeter(w http.ResponseWriter, r *http.Request) @@ -1451,7 +1421,7 @@ type Unimplemented struct{} // List customers // (GET /openmeter/customers) -func (_ Unimplemented) ListCustomers(w http.ResponseWriter, r *http.Request, params ListCustomersParams) { +func (_ Unimplemented) ListCustomers(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } @@ -1493,7 +1463,7 @@ func (_ Unimplemented) IngestMeteringEvents(w http.ResponseWriter, r *http.Reque // List meters // (GET /openmeter/meters) -func (_ Unimplemented) ListMeters(w http.ResponseWriter, r *http.Request, params ListMetersParams) { +func (_ Unimplemented) ListMeters(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } @@ -1520,38 +1490,8 @@ type MiddlewareFunc func(http.Handler) http.Handler // ListCustomers operation middleware func (siw *ServerInterfaceWrapper) ListCustomers(w http.ResponseWriter, r *http.Request) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params ListCustomersParams - - // ------------- Optional query parameter "page" ------------- - - err = runtime.BindQueryParameter("form", true, false, "page", r.URL.Query(), ¶ms.Page) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err}) - return - } - - // ------------- Optional query parameter "sort" ------------- - - err = runtime.BindQueryParameter("form", false, false, "sort", r.URL.Query(), ¶ms.Sort) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err}) - return - } - - // ------------- Optional query parameter "filter" ------------- - - err = runtime.BindQueryParameter("deepObject", true, false, "filter", r.URL.Query(), ¶ms.Filter) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter", Err: err}) - return - } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListCustomers(w, r, params) + siw.Handler.ListCustomers(w, r) })) for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { @@ -1563,7 +1503,6 @@ func (siw *ServerInterfaceWrapper) ListCustomers(w http.ResponseWriter, r *http. // CreateCustomer operation middleware func (siw *ServerInterfaceWrapper) CreateCustomer(w http.ResponseWriter, r *http.Request) { - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.CreateCustomer(w, r) })) @@ -1577,7 +1516,6 @@ func (siw *ServerInterfaceWrapper) CreateCustomer(w http.ResponseWriter, r *http // DeleteCustomer operation middleware func (siw *ServerInterfaceWrapper) DeleteCustomer(w http.ResponseWriter, r *http.Request) { - var err error // ------------- Path parameter "customerId" ------------- @@ -1602,7 +1540,6 @@ func (siw *ServerInterfaceWrapper) DeleteCustomer(w http.ResponseWriter, r *http // GetCustomer operation middleware func (siw *ServerInterfaceWrapper) GetCustomer(w http.ResponseWriter, r *http.Request) { - var err error // ------------- Path parameter "customerId" ------------- @@ -1627,7 +1564,6 @@ func (siw *ServerInterfaceWrapper) GetCustomer(w http.ResponseWriter, r *http.Re // UpdateCustomer operation middleware func (siw *ServerInterfaceWrapper) UpdateCustomer(w http.ResponseWriter, r *http.Request) { - var err error // ------------- Path parameter "customerId" ------------- @@ -1652,7 +1588,6 @@ func (siw *ServerInterfaceWrapper) UpdateCustomer(w http.ResponseWriter, r *http // UpsertCustomer operation middleware func (siw *ServerInterfaceWrapper) UpsertCustomer(w http.ResponseWriter, r *http.Request) { - var err error // ------------- Path parameter "customerId" ------------- @@ -1677,7 +1612,6 @@ func (siw *ServerInterfaceWrapper) UpsertCustomer(w http.ResponseWriter, r *http // IngestMeteringEvents operation middleware func (siw *ServerInterfaceWrapper) IngestMeteringEvents(w http.ResponseWriter, r *http.Request) { - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.IngestMeteringEvents(w, r) })) @@ -1691,22 +1625,8 @@ func (siw *ServerInterfaceWrapper) IngestMeteringEvents(w http.ResponseWriter, r // ListMeters operation middleware func (siw *ServerInterfaceWrapper) ListMeters(w http.ResponseWriter, r *http.Request) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params ListMetersParams - - // ------------- Optional query parameter "page" ------------- - - err = runtime.BindQueryParameter("form", true, false, "page", r.URL.Query(), ¶ms.Page) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err}) - return - } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListMeters(w, r, params) + siw.Handler.ListMeters(w, r) })) for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { @@ -1718,7 +1638,6 @@ func (siw *ServerInterfaceWrapper) ListMeters(w http.ResponseWriter, r *http.Req // CreateMeter operation middleware func (siw *ServerInterfaceWrapper) CreateMeter(w http.ResponseWriter, r *http.Request) { - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.CreateMeter(w, r) })) @@ -1732,7 +1651,6 @@ func (siw *ServerInterfaceWrapper) CreateMeter(w http.ResponseWriter, r *http.Re // GetMeter operation middleware func (siw *ServerInterfaceWrapper) GetMeter(w http.ResponseWriter, r *http.Request) { - var err error // ------------- Path parameter "meterIdOrKey" ------------- @@ -1904,121 +1822,119 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - - "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", - "5M+QpyGvz9b/PwAA//8AhsIQgpYAAA==", + "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", + "/wcAAP//keEW0ISVAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/codegen.yaml b/api/v3/codegen.yaml index 4964545953..846e33bcad 100644 --- a/api/v3/codegen.yaml +++ b/api/v3/codegen.yaml @@ -14,3 +14,8 @@ output: ./api.gen.go output-options: nullable-type: true skip-prune: true + overlay: + path: ./overlay.yaml + # strict: true + # user-templates: + # chi/chi-middleware.tmpl: ./templates/chi/chi-middleware.tmpl diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index d0b0c1674f..76833ab733 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -43,15 +43,14 @@ func NewCustomerHandler( } type ( - ListCustomersParams = api.ListCustomersParams ListCustomersRequest = customer.ListCustomersInput ListCustomersResponse = response.CursorPaginationResponse[Customer] - ListCustomersHandler httptransport.HandlerWithArgs[ListCustomersRequest, ListCustomersResponse, ListCustomersParams] + ListCustomersHandler httptransport.Handler[ListCustomersRequest, ListCustomersResponse] ) func (h *customerHandler) ListCustomers() ListCustomersHandler { - return httptransport.NewHandlerWithArgs( - func(ctx context.Context, r *http.Request, params ListCustomersParams) (ListCustomersRequest, error) { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (ListCustomersRequest, error) { ns, err := h.resolveNamespace(ctx) if err != nil { return ListCustomersRequest{}, err diff --git a/api/v3/handlers/meters.go b/api/v3/handlers/meters.go index a59d14ab38..6d72fbef46 100644 --- a/api/v3/handlers/meters.go +++ b/api/v3/handlers/meters.go @@ -4,7 +4,6 @@ import ( "context" "net/http" - api "github.com/openmeterio/openmeter/api/v3" "github.com/openmeterio/openmeter/api/v3/apierrors" response "github.com/openmeterio/openmeter/api/v3/response" "github.com/openmeterio/openmeter/openmeter/meter" @@ -38,16 +37,15 @@ func NewMeterHandler( } type ( - ListMetersParams = api.ListMetersParams ListMetersRequest = meter.ListMetersParams ListMetersResponse = response.CursorPaginationResponse[Meter] - ListMetersHandler httptransport.HandlerWithArgs[ListMetersRequest, ListMetersResponse, ListMetersParams] + ListMetersHandler httptransport.Handler[ListMetersRequest, ListMetersResponse] ) // ListMeters returns a handler for listing meters. func (h *meterHandler) ListMeters() ListMetersHandler { - return httptransport.NewHandlerWithArgs( - func(ctx context.Context, r *http.Request, params ListMetersParams) (ListMetersRequest, error) { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (ListMetersRequest, error) { ns, err := h.resolveNamespace(ctx) if err != nil { return ListMetersRequest{}, err diff --git a/api/v3/overlay.yaml b/api/v3/overlay.yaml new file mode 100644 index 0000000000..58c0314e52 --- /dev/null +++ b/api/v3/overlay.yaml @@ -0,0 +1,15 @@ +overlay: 1.0.0 +info: + title: Remove list query params + version: 0.1.0 + description: Remove cursor, sort, and filter query parameters from list endpoints. +actions: + - description: Drop the shared cursor pagination query parameter from list operations. + target: $.paths.*.*.parameters[?(@["$ref"]=="#/components/parameters/CursorPageQuery")] + remove: true + - description: Drop the customer sort parameter wherever it is referenced by list operations. + target: $.paths.*.*.parameters[?(@["$ref"]=="#/components/parameters/ListCustomersParams.sort")] + remove: true + - description: Drop the customer filter parameter wherever it is referenced by list operations. + target: $.paths.*.*.parameters[?(@["$ref"]=="#/components/parameters/ListCustomersParams.filter")] + remove: true diff --git a/api/v3/request/query/query.go b/api/v3/request/query/query.go index 8c34a9c281..c643258c19 100644 --- a/api/v3/request/query/query.go +++ b/api/v3/request/query/query.go @@ -1546,7 +1546,7 @@ func StructToMap(obj interface{}) (map[string]interface{}, error) { // // This function provides excellent performance with automatic type detection, // making it suitable for high-throughput applications. -func Unmarshal(ctx context.Context, queryString string, v interface{}, opts ...*ParseOptions) *apierrors.BaseAPIError { +func Unmarshal(ctx context.Context, queryString string, v interface{}, opts ...*ParseOptions) error { if v == nil { return newQueryAPIError(ctx, fmt.Errorf("unmarshal target cannot be nil"), "") } diff --git a/api/v3/request/query/query_test.go b/api/v3/request/query/query_test.go index 5620e7dbc1..4cc5e06ef4 100644 --- a/api/v3/request/query/query_test.go +++ b/api/v3/request/query/query_test.go @@ -892,7 +892,7 @@ func TestMarshalUnmarshalRoundTrip(t *testing.T) { // Unmarshal back if apiErr := Unmarshal(ctx, queryString, target); apiErr != nil { - t.Fatalf("Unmarshal() error = %v (%T) underlying=%#v invalid=%#v", apiErr, apiErr, apiErr.Unwrap(), apiErr.InvalidParameters) + require.Error(t, apiErr) } // Compare (dereference pointer) diff --git a/api/v3/server/customers.go b/api/v3/server/customers.go index 6da87931f9..f69eaac00c 100644 --- a/api/v3/server/customers.go +++ b/api/v3/server/customers.go @@ -16,8 +16,8 @@ func (s *Server) GetCustomer(w http.ResponseWriter, r *http.Request, customerId s.customerHandler.GetCustomer().With(customerId).ServeHTTP(w, r) } -func (s *Server) ListCustomers(w http.ResponseWriter, r *http.Request, params api.ListCustomersParams) { - s.customerHandler.ListCustomers().With(params).ServeHTTP(w, r) +func (s *Server) ListCustomers(w http.ResponseWriter, r *http.Request) { + s.customerHandler.ListCustomers().ServeHTTP(w, r) } func (s *Server) UpsertCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { diff --git a/api/v3/server/meters.go b/api/v3/server/meters.go index 2aca740966..26f0fa2143 100644 --- a/api/v3/server/meters.go +++ b/api/v3/server/meters.go @@ -8,8 +8,8 @@ import ( "github.com/openmeterio/openmeter/api/v3/apierrors" ) -func (s *Server) ListMeters(w http.ResponseWriter, r *http.Request, params api.ListMetersParams) { - s.meterHandler.ListMeters().With(params).ServeHTTP(w, r) +func (s *Server) ListMeters(w http.ResponseWriter, r *http.Request) { + s.meterHandler.ListMeters().ServeHTTP(w, r) } func (s *Server) GetMeter(w http.ResponseWriter, r *http.Request, meterIdOrKey api.ULIDOrResourceKey) { diff --git a/api/v3/server/server.go b/api/v3/server/server.go index 424491a8fe..1a7aa4a810 100644 --- a/api/v3/server/server.go +++ b/api/v3/server/server.go @@ -64,10 +64,10 @@ func NewServer(config *Config) (*Server, error) { Options: openapi3filter.Options{ // NoOp authenticationFunc as it's handled in another middleware // this is based on `security` property on OpenAPI Spec - AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, - MultiError: true, - SkipSettingDefaults: false, - // ExcludeRequestQueryParams: true, + AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, + MultiError: true, + SkipSettingDefaults: false, + ExcludeRequestQueryParams: true, }, })} diff --git a/api/v3/templates/chi/chi-middleware.tmpl b/api/v3/templates/chi/chi-middleware.tmpl new file mode 100644 index 0000000000..97866fca6a --- /dev/null +++ b/api/v3/templates/chi/chi-middleware.tmpl @@ -0,0 +1,266 @@ +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +{{range .}}{{$opid := .OperationId}} + +// {{$opid}} operation middleware +func (siw *ServerInterfaceWrapper) {{$opid}}(w http.ResponseWriter, r *http.Request) { + {{if or .RequiresParamObject (gt (len .PathParams) 0) }} + var err error + {{end}} + + {{range .PathParams}}// ------------- Path parameter "{{.ParamName}}" ------------- + var {{$varName := .GoVariableName}}{{$varName}} {{.TypeDef}} + + {{if .IsPassThrough}} + {{$varName}} = chi.URLParam(r, "{{.ParamName}}") + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(chi.URLParam(r, "{{.ParamName}}")), &{{$varName}}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", chi.URLParam(r, "{{.ParamName}}"), &{{$varName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: {{.Explode}}, Required: {{.Required}}}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + {{end}} + + {{end}} + + {{if .SecurityDefinitions -}} + ctx := r.Context() +{{range .SecurityDefinitions}} + ctx = context.WithValue(ctx, {{.ProviderName | sanitizeGoIdentity | ucFirst}}Scopes, {{toStringArray .Scopes}}) +{{end}} + r = r.WithContext(ctx) + {{end}} + + {{if .RequiresParamObject}} + // Parameter object where we will unmarshal all parameters from the context + var params {{.OperationId}}Params + + {{range $paramIdx, $param := .QueryParams}} + {{- if (or (or .Required .IsPassThrough) (or .IsJson .IsStyled)) -}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{ end }} + {{ if (or (or .Required .IsPassThrough) .IsJson) }} + if paramValue := r.URL.Query().Get("{{.ParamName}}"); paramValue != "" { + + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + siw.ErrorHandlerFunc(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + return + }{{end}} + {{end}} + {{if .IsStyled}} + err = runtime.BindQueryParameter("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", r.URL.Query(), ¶ms.{{.GoName}}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + {{end}} + {{end}} + + {{if .HeaderParams}} + headers := r.Header + + {{range .HeaderParams}}// ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "{{.ParamName}}", Count: n}) + return + } + + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer }}&{{end}}valueList[0] + {{end}} + + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + {{end}} + + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + {{end}} + + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + + } {{if .Required}}else { + err := fmt.Errorf("Header parameter {{.ParamName}} is required, but not found") + siw.ErrorHandlerFunc(w, r, &RequiredHeaderError{ParamName: "{{.ParamName}}", Err: err}) + return + }{{end}} + + {{end}} + {{end}} + + {{range .CookieParams}} + { + var cookie *http.Cookie + + if cookie, err = r.Cookie("{{.ParamName}}"); err == nil { + + {{- if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}cookie.Value + {{end}} + + {{- if .IsJson}} + var value {{.TypeDef}} + var decoded string + decoded, err := url.QueryUnescape(cookie.Value) + if err != nil { + err = fmt.Errorf("Error unescaping cookie parameter '{{.ParamName}}'") + siw.ErrorHandlerFunc(w, r, &UnescapedCookieParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + + err = json.Unmarshal([]byte(decoded), &value) + if err != nil { + siw.ErrorHandlerFunc(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + + {{- if .IsStyled}} + var value {{.TypeDef}} + err = runtime.BindStyledParameterWithOptions("simple", "{{.ParamName}}", cookie.Value, &value, runtime.BindStyledParameterOptions{Explode: {{.Explode}}, Required: {{.Required}}}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + + } + + {{- if .Required}} else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + return + } + {{- end}} + } + {{end}} + {{end}} + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.{{.OperationId}}(w, r{{genParamNames .PathParams}}{{if .RequiresParamObject}}, params{{end}}) + })) + + {{if opts.Compatibility.ApplyChiMiddlewareFirstToLast}} + for i := len(siw.HandlerMiddlewares) -1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + {{else}} + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + {{end}} + + handler.ServeHTTP(w, r) +} +{{end}} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} From 3eaad7d396acbccd5fec930b7cdd003e9f9eca5d Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Tue, 9 Dec 2025 19:57:35 +0100 Subject: [PATCH 13/18] feat: error handler --- api/v3/handlers/customer.go | 3 +++ api/v3/request/request.go | 5 +++-- api/v3/server/server.go | 7 +++++-- openmeter/server/server.go | 3 ++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index 76833ab733..4ebb29b5f8 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -3,6 +3,7 @@ package handlers import ( "context" "fmt" + "log/slog" "net/http" "github.com/samber/lo" @@ -93,6 +94,8 @@ func (h *customerHandler) ListCustomers() ListCustomersHandler { } } + slog.Info("request", "request", req) + return req, nil }, func(ctx context.Context, request ListCustomersRequest) (ListCustomersResponse, error) { diff --git a/api/v3/request/request.go b/api/v3/request/request.go index 3e67e26e80..45f122da2b 100644 --- a/api/v3/request/request.go +++ b/api/v3/request/request.go @@ -93,8 +93,9 @@ func GetAttributes(r *http.Request, opts ...AttributesOption) (*QueryAttributes, a := &QueryAttributes{ Pagination: Pagination{ - kind: conf.paginationKind, - Size: conf.defaultPageSize, + kind: conf.paginationKind, + Size: conf.defaultPageSize, + Number: 1, }, } diff --git a/api/v3/server/server.go b/api/v3/server/server.go index 1a7aa4a810..c13a0f32c3 100644 --- a/api/v3/server/server.go +++ b/api/v3/server/server.go @@ -18,11 +18,14 @@ import ( "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/meter" "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" + "github.com/openmeterio/openmeter/pkg/errorsx" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" ) type Config struct { BaseURL string NamespaceDecoder namespacedriver.NamespaceDecoder + ErrorHandler errorsx.Handler // services CustomerService customer.Service @@ -80,8 +83,8 @@ func NewServer(config *Config) (*Server, error) { return ns, nil } - customerHandler := handlers.NewCustomerHandler(resolveNamespace, config.CustomerService) - meterHandler := handlers.NewMeterHandler(resolveNamespace, config.MeterService) + customerHandler := handlers.NewCustomerHandler(resolveNamespace, config.CustomerService, httptransport.WithErrorHandler(config.ErrorHandler)) + meterHandler := handlers.NewMeterHandler(resolveNamespace, config.MeterService, httptransport.WithErrorHandler(config.ErrorHandler)) return &Server{ Config: config, diff --git a/openmeter/server/server.go b/openmeter/server/server.go index c9e4ab5bdb..ab94bbdfb0 100644 --- a/openmeter/server/server.go +++ b/openmeter/server/server.go @@ -98,9 +98,10 @@ func NewServer(config *Config) (*Server, error) { v3API, err := v3server.NewServer(&v3server.Config{ BaseURL: "/api/v3", + NamespaceDecoder: staticNamespaceDecoder, + ErrorHandler: config.RouterConfig.ErrorHandler, CustomerService: config.RouterConfig.Customer, MeterService: config.RouterConfig.MeterManageService, - NamespaceDecoder: staticNamespaceDecoder, }) if err != nil { slog.Error("failed to create v3 API", "error", err) From 835767c61880a220fa341e639fce0208e29570ae Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:25:04 +0100 Subject: [PATCH 14/18] feat: pagination --- api/v3/handlers/customer.go | 11 ++++++---- api/v3/request/sort.go | 4 ++-- api/v3/response/pagination.go | 25 ++++++++++++++++++++++ pkg/pagination/v2/pagination.go | 38 ++++++++++++++++++++++++++++++++- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index 4ebb29b5f8..b4f98abca9 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -45,7 +45,7 @@ func NewCustomerHandler( type ( ListCustomersRequest = customer.ListCustomersInput - ListCustomersResponse = response.CursorPaginationResponse[Customer] + ListCustomersResponse = response.OffsetPaginationResponse[Customer] ListCustomersHandler httptransport.Handler[ListCustomersRequest, ListCustomersResponse] ) @@ -111,9 +111,12 @@ func (h *customerHandler) ListCustomers() ListCustomersHandler { }) // Map the customers to the API - r := response.NewCursorPaginationResponse(customers) - // TODO: set the size of the page from the request params - // r.Meta.Page.Size = request.Page.Size + r := response.NewOffsetPaginationResponse(customers, response.OffsetMetaPage{ + Size: request.Page.PageSize, + Number: request.Page.PageNumber, + Total: lo.ToPtr(resp.TotalCount), + }) + return r, nil }, commonhttp.JSONResponseEncoderWithStatus[ListCustomersResponse](http.StatusOK), diff --git a/api/v3/request/sort.go b/api/v3/request/sort.go index d0dbd45d1b..4dd161bcba 100644 --- a/api/v3/request/sort.go +++ b/api/v3/request/sort.go @@ -30,8 +30,8 @@ func (s SortOrder) Validate() error { } type SortBy struct { - Field string `query:"field"` - Order SortOrder `query:"order"` + Field string + Order SortOrder } func (s SortBy) Validate() error { diff --git a/api/v3/response/pagination.go b/api/v3/response/pagination.go index 8743a4cdbe..4c2927a04f 100644 --- a/api/v3/response/pagination.go +++ b/api/v3/response/pagination.go @@ -38,6 +38,22 @@ type CursorPaginationResponse[T any] struct { Meta CursorMeta `json:"meta"` } +type OffsetPaginationResponse[T any] struct { + Data []T `json:"data"` + Meta OffsetMeta `json:"meta"` +} + +type OffsetMeta struct { + Page OffsetMetaPage `json:"page"` +} + +type OffsetMetaPage struct { + Size int `json:"size"` + Number int `json:"number"` + Total *int `json:"total,omitempty"` + EstimatedTotal *int `json:"estimatedTotal,omitempty"` +} + // NewCursorPaginationResponse creates a new pagination response from an ordered list of items. // T must implement the Item interface for cursor generation. func NewCursorPaginationResponse[T pagination.Item](items []T) CursorPaginationResponse[T] { @@ -86,3 +102,12 @@ func NewCursorPaginationResponse[T pagination.Item](items []T) CursorPaginationR // return fmt.Sprintf("%s?%s", rr.URL.Path, rr.URL.RawQuery), nil // } + +func NewOffsetPaginationResponse[T any](items []T, page OffsetMetaPage) OffsetPaginationResponse[T] { + return OffsetPaginationResponse[T]{ + Data: items, + Meta: OffsetMeta{ + Page: page, + }, + } +} diff --git a/pkg/pagination/v2/pagination.go b/pkg/pagination/v2/pagination.go index d70c322020..ed28547ffc 100644 --- a/pkg/pagination/v2/pagination.go +++ b/pkg/pagination/v2/pagination.go @@ -1,6 +1,16 @@ package pagination -import "context" +import ( + "context" + "errors" +) + +var ( + ErrCursorPaginationSizeInvalid = errors.New("size must be greater than 0") + ErrCursorPaginationRange = errors.New("range pagination not supported, both before and after cursor were defined") + ErrCursorPaginationAfterInvalid = errors.New("after cursor is invalid") + ErrCursorPaginationBeforeInvalid = errors.New("before cursor is invalid") +) // Item is the interface that must be implemented by items used in cursor pagination. // It provides access to the time and ID fields needed for cursor generation. @@ -26,3 +36,29 @@ func (p *paginator[T]) Paginate(ctx context.Context, cursor *Cursor) (Result[T], func NewPaginator[T any](fn func(ctx context.Context, cursor *Cursor) (Result[T], error)) Paginator[T] { return &paginator[T]{fn: fn} } + +type CursorPagination struct { + Size int + After *Cursor + Before *Cursor +} + +func (p *CursorPagination) Validate() error { + if p.Size < 1 { + return ErrCursorPaginationSizeInvalid + } + + if p.After != nil && p.Before != nil { + return ErrCursorPaginationRange + } + + if p.After != nil && p.After.Validate() != nil { + return ErrCursorPaginationAfterInvalid + } + + if p.Before != nil && p.Before.Validate() != nil { + return ErrCursorPaginationBeforeInvalid + } + + return nil +} From eab1b09b9e47789c597f2793616221711d7b2a36 Mon Sep 17 00:00:00 2001 From: Gergely Tamas Kurucz Date: Tue, 9 Dec 2025 21:16:02 +0100 Subject: [PATCH 15/18] feat: add v3 delete customer handler and implementation --- api/v3/handlers/customer.go | 62 ++++++++++++++++++++++++++++++++++++- api/v3/server/customers.go | 2 +- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index b4f98abca9..80cd8df501 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -14,13 +14,14 @@ import ( "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" ) type CustomerHandler interface { ListCustomers() ListCustomersHandler CreateCustomer() CreateCustomerHandler - // DeleteCustomer() DeleteCustomerHandler + DeleteCustomer() DeleteCustomerHandler GetCustomer() GetCustomerHandler // UpdateCustomer() UpdateCustomerHandler } @@ -214,3 +215,62 @@ func (h *customerHandler) GetCustomer() GetCustomerHandler { )..., ) } + +type ( + DeleteCustomerRequest struct { + Namespace string + CustomerIDOrKey string + } + DeleteCustomerResponse = interface{} + DeleteCustomerParams = string + DeleteCustomerHandler httptransport.HandlerWithArgs[DeleteCustomerRequest, DeleteCustomerResponse, DeleteCustomerParams] +) + +// DeleteCustomer returns a handler for deleting a customer. +func (h *customerHandler) DeleteCustomer() DeleteCustomerHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, customerIDOrKey DeleteCustomerParams) (DeleteCustomerRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return DeleteCustomerRequest{}, err + } + + return DeleteCustomerRequest{ + Namespace: ns, + CustomerIDOrKey: customerIDOrKey, + }, nil + }, + func(ctx context.Context, request DeleteCustomerRequest) (DeleteCustomerResponse, error) { + // TODO: we should not allow key identifier for mutable operations + // Get the customer + cus, err := h.service.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerIDOrKey: &customer.CustomerIDOrKey{ + IDOrKey: request.CustomerIDOrKey, + Namespace: request.Namespace, + }, + }) + if err != nil { + return DeleteCustomerRequest{}, err + } + + if cus != nil && cus.IsDeleted() { + return DeleteCustomerRequest{}, + models.NewGenericPreConditionFailedError( + fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + err = h.service.DeleteCustomer(ctx, cus.GetID()) + if err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.JSONResponseEncoderWithStatus[DeleteCustomerResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("delete-customer"), + )..., + ) +} diff --git a/api/v3/server/customers.go b/api/v3/server/customers.go index f69eaac00c..7c23d0410c 100644 --- a/api/v3/server/customers.go +++ b/api/v3/server/customers.go @@ -29,5 +29,5 @@ func (s *Server) UpdateCustomer(w http.ResponseWriter, r *http.Request, customer } func (s *Server) DeleteCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { - apierrors.NewNotImplementedError(r.Context(), errors.New("not implemented")).HandleAPIError(w, r) + s.customerHandler.DeleteCustomer().With(customerId).ServeHTTP(w, r) } From 40a7e4d830596aaf7b519cdd22a593ca402c8a0a Mon Sep 17 00:00:00 2001 From: Gergely Tamas Kurucz Date: Tue, 9 Dec 2025 21:44:38 +0100 Subject: [PATCH 16/18] feat: v3 delete should return 204 due to idempotency --- api/v3/handlers/customer.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index 80cd8df501..54dbe85f0d 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -14,7 +14,6 @@ import ( "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" - "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" ) @@ -253,11 +252,10 @@ func (h *customerHandler) DeleteCustomer() DeleteCustomerHandler { return DeleteCustomerRequest{}, err } + // Idempotent operation, we return 204 when a customer is already deleted. + // Rationale: https://kong-aip.netlify.app/aip/135/ if cus != nil && cus.IsDeleted() { - return DeleteCustomerRequest{}, - models.NewGenericPreConditionFailedError( - fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), - ) + return nil, nil } err = h.service.DeleteCustomer(ctx, cus.GetID()) From 923fb421e9b050d407d42d69a140255301f16679 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:49:09 +0100 Subject: [PATCH 17/18] feat: error mapping --- api/v3/apierrors/errors.go | 41 ++++++++++++------------------------- api/v3/handlers/customer.go | 5 +++++ api/v3/handlers/meters.go | 1 + api/v3/server/server.go | 7 +++---- 4 files changed, 22 insertions(+), 32 deletions(-) diff --git a/api/v3/apierrors/errors.go b/api/v3/apierrors/errors.go index 7101bc0fb0..3236ffb07f 100644 --- a/api/v3/apierrors/errors.go +++ b/api/v3/apierrors/errors.go @@ -9,6 +9,9 @@ import ( "strings" "github.com/openmeterio/openmeter/api/v3/render" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport/encoder" + "github.com/samber/lo" ) type config struct { @@ -186,35 +189,17 @@ func (bae *BaseAPIError) HandleAPIError( w http.ResponseWriter, r *http.Request, ) { - // handle: write the response to the caller - w.Header().Set(ContentTypeKey, ContentTypeProblemValue) - _ = render.RenderJSON(w, bae, render.WithStatus(bae.Status)) + _ = render.RenderJSON(w, bae, render.WithHeader(ContentTypeKey, ContentTypeProblemValue), render.WithStatus(bae.Status)) } -// handleEmptySet returns a 200 with an empty meta/data list. -func (bae *BaseAPIError) handleEmptySet(w http.ResponseWriter) { - w.WriteHeader(http.StatusOK) - if bae.Type == EmptySetType { - _ = render.RenderJSON(w, struct { - Meta struct { - Page struct { - Size int `json:"size"` - Total int `json:"total"` - Number int `json:"number"` - } `json:"page"` - } `json:"meta"` - Data []any `json:"data"` - }{}) - } else { - _ = render.RenderJSON(w, struct { - Meta struct { - Page struct { - Size int `json:"size"` - Previous *string `json:"previous"` - Next *string `json:"next"` - } `json:"page"` - } `json:"meta"` - Data []any `json:"data"` - }{}) +// GenericErrorEncoder is an error encoder that encodes the error as a generic error. +func GenericErrorEncoder() encoder.ErrorEncoder { + return func(ctx context.Context, err error, w http.ResponseWriter, r *http.Request) bool { + if err, ok := lo.ErrorsAs[*BaseAPIError](err); ok { + err.HandleAPIError(w, r) + return true + } + + return commonhttp.HandleIssueIfHTTPStatusKnown(ctx, err, w) } } diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index 54dbe85f0d..632c839538 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -9,6 +9,7 @@ import ( "github.com/samber/lo" api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" "github.com/openmeterio/openmeter/api/v3/request" "github.com/openmeterio/openmeter/api/v3/response" "github.com/openmeterio/openmeter/openmeter/customer" @@ -123,6 +124,7 @@ func (h *customerHandler) ListCustomers() ListCustomersHandler { httptransport.AppendOptions( h.options, httptransport.WithOperationName("list-customers"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), )..., ) } @@ -167,6 +169,7 @@ func (h *customerHandler) CreateCustomer() CreateCustomerHandler { httptransport.AppendOptions( h.options, httptransport.WithOperationName("create-customer"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), )..., ) } @@ -211,6 +214,7 @@ func (h *customerHandler) GetCustomer() GetCustomerHandler { httptransport.AppendOptions( h.options, httptransport.WithOperationName("get-customer"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), )..., ) } @@ -269,6 +273,7 @@ func (h *customerHandler) DeleteCustomer() DeleteCustomerHandler { httptransport.AppendOptions( h.options, httptransport.WithOperationName("delete-customer"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), )..., ) } diff --git a/api/v3/handlers/meters.go b/api/v3/handlers/meters.go index 6d72fbef46..27d8afc39a 100644 --- a/api/v3/handlers/meters.go +++ b/api/v3/handlers/meters.go @@ -82,6 +82,7 @@ func (h *meterHandler) ListMeters() ListMetersHandler { httptransport.AppendOptions( h.options, httptransport.WithOperationName("listMeters"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), )..., ) } diff --git a/api/v3/server/server.go b/api/v3/server/server.go index c13a0f32c3..2f7e48af4f 100644 --- a/api/v3/server/server.go +++ b/api/v3/server/server.go @@ -109,10 +109,9 @@ func (s *Server) RegisterRoutes(r chi.Router) { _ = api.HandlerWithOptions(s, api.ChiServerOptions{ BaseRouter: r, Middlewares: s.middlewares, - // ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { - // config.RouterConfig.ErrorHandler.HandleContext(r.Context(), err) - // errorHandlerReply(w, r, err) - // }, + ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + s.ErrorHandler.HandleContext(r.Context(), err) + }, }) }) } From 6f090b52f582aaef5792706c7154daa292992faa Mon Sep 17 00:00:00 2001 From: Gergely Tamas Kurucz Date: Wed, 10 Dec 2025 18:54:16 +0100 Subject: [PATCH 18/18] feat: add v3 customer update (PATCH) handler --- api/v3/handlers/convert.gen.go | 16 +++++++ api/v3/handlers/convert.go | 4 ++ api/v3/handlers/customer.go | 84 ++++++++++++++++++++++++++++++++-- api/v3/server/customers.go | 2 +- 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/api/v3/handlers/convert.gen.go b/api/v3/handlers/convert.gen.go index d18a572848..6bbdf65ec8 100644 --- a/api/v3/handlers/convert.gen.go +++ b/api/v3/handlers/convert.gen.go @@ -128,6 +128,22 @@ func init() { v3MeterPaginatedResponse.Meta = responseCursorMetaToV3CursorMeta(source.Meta) return v3MeterPaginatedResponse, nil } + ConvertUpdateCustomerRequestToCustomerMutate = func(source v3.UpdateCustomerRequest) customer.CustomerMutate { + var customerCustomerMutate customer.CustomerMutate + if source.Name != nil { + customerCustomerMutate.Name = *source.Name + } + customerCustomerMutate.Description = source.Description + customerCustomerMutate.UsageAttribution = pV3BillingCustomerUsageAttributionToCustomerCustomerUsageAttribution(source.UsageAttribution) + customerCustomerMutate.PrimaryEmail = source.PrimaryEmail + if source.Currency != nil { + currencyxCode := currencyx.Code(*source.Currency) + customerCustomerMutate.Currency = ¤cyxCode + } + customerCustomerMutate.BillingAddress = pV3BillingAddressToPModelsAddress(source.BillingAddress) + customerCustomerMutate.Metadata = pV3LabelsToPModelsMetadata(source.Labels) + return customerCustomerMutate + } } func customerCustomerUsageAttributionToPV3BillingCustomerUsageAttribution(source customer.CustomerUsageAttribution) *v3.BillingCustomerUsageAttribution { var v3BillingCustomerUsageAttribution v3.BillingCustomerUsageAttribution diff --git a/api/v3/handlers/convert.go b/api/v3/handlers/convert.go index fb392abb76..8e096c3077 100644 --- a/api/v3/handlers/convert.go +++ b/api/v3/handlers/convert.go @@ -36,6 +36,10 @@ var ( // goverter:map Labels Metadata // goverter:ignore Annotation ConvertCreateCustomerRequestToCustomerMutate func(createCustomerRequest api.CreateCustomerRequest) customer.CustomerMutate + // goverter:map Labels Metadata + // goverter:ignore Annotation + // goverter:ignore Key + ConvertUpdateCustomerRequestToCustomerMutate func(updateCustomerRequest api.UpdateCustomerRequest) customer.CustomerMutate ConvertCustomerListResponse func(customers response.CursorPaginationResponse[customer.Customer]) api.CustomerPaginatedResponse // goverter:map Metadata Labels // goverter:map GroupBy Dimensions diff --git a/api/v3/handlers/customer.go b/api/v3/handlers/customer.go index 632c839538..64f678142c 100644 --- a/api/v3/handlers/customer.go +++ b/api/v3/handlers/customer.go @@ -3,7 +3,6 @@ package handlers import ( "context" "fmt" - "log/slog" "net/http" "github.com/samber/lo" @@ -23,7 +22,7 @@ type CustomerHandler interface { CreateCustomer() CreateCustomerHandler DeleteCustomer() DeleteCustomerHandler GetCustomer() GetCustomerHandler - // UpdateCustomer() UpdateCustomerHandler + UpdateCustomer() UpdateCustomerHandler } type customerHandler struct { @@ -95,8 +94,6 @@ func (h *customerHandler) ListCustomers() ListCustomersHandler { } } - slog.Info("request", "request", req) - return req, nil }, func(ctx context.Context, request ListCustomersRequest) (ListCustomersResponse, error) { @@ -277,3 +274,82 @@ func (h *customerHandler) DeleteCustomer() DeleteCustomerHandler { )..., ) } + +type ( + UpdateCustomerRequest struct { + Namespace string + CustomerID string + CustomerMutate customer.CustomerMutate + } + UpdateCustomerParams = string + UpdateCustomerResponse = api.BillingCustomer + UpdateCustomerHandler httptransport.HandlerWithArgs[UpdateCustomerRequest, UpdateCustomerResponse, UpdateCustomerParams] +) + +// UpdateCustomer returns a handler for updating a customer. +func (h *customerHandler) UpdateCustomer() UpdateCustomerHandler { + return httptransport.NewHandlerWithArgs[UpdateCustomerRequest, UpdateCustomerResponse, UpdateCustomerParams]( + func(ctx context.Context, r *http.Request, customerID UpdateCustomerParams) (UpdateCustomerRequest, error) { + body := api.UpdateCustomerRequest{} + if err := request.ParseBody(r, &body); err != nil { + return UpdateCustomerRequest{}, fmt.Errorf("field to decode update customer request: %w", err) + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpdateCustomerRequest{}, err + } + + req := UpdateCustomerRequest{ + Namespace: ns, + CustomerID: customerID, + // Key cannot be updated according to api.UpdateCustomerRequest. + // Therefore, at this point we don't have a key yet. It is ignored in this conversion. + CustomerMutate: ConvertUpdateCustomerRequestToCustomerMutate(body), + } + + return req, nil + }, + func(ctx context.Context, request UpdateCustomerRequest) (UpdateCustomerResponse, error) { + // TODO: we should not allow key identifier for mutable operations + // Get the customer + cus, err := h.service.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerID: &customer.CustomerID{ + ID: request.CustomerID, + Namespace: request.Namespace, + }, + }) + if err != nil { + return UpdateCustomerResponse{}, err + } + + if cus != nil && cus.IsDeleted() { + return UpdateCustomerResponse{}, + apierrors.NewPreconditionFailedError( + ctx, + fmt.Sprintf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + updatedCustomer, err := h.service.UpdateCustomer(ctx, customer.UpdateCustomerInput{ + CustomerID: cus.GetID(), + CustomerMutate: request.CustomerMutate, + }) + if err != nil { + return UpdateCustomerResponse{}, err + } + + if updatedCustomer == nil { + return UpdateCustomerResponse{}, fmt.Errorf("failed to update customer") + } + + return ConvertCustomerRequestToBillingCustomer(*updatedCustomer), nil + }, + commonhttp.JSONResponseEncoderWithStatus[UpdateCustomerResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("update-customer"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/server/customers.go b/api/v3/server/customers.go index 7c23d0410c..37547c6205 100644 --- a/api/v3/server/customers.go +++ b/api/v3/server/customers.go @@ -25,7 +25,7 @@ func (s *Server) UpsertCustomer(w http.ResponseWriter, r *http.Request, customer } func (s *Server) UpdateCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { - apierrors.NewNotImplementedError(r.Context(), errors.New("not implemented")).HandleAPIError(w, r) + s.customerHandler.UpdateCustomer().With(customerId).ServeHTTP(w, r) } func (s *Server) DeleteCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) {