diff --git a/rust/domains-client/openapi/domains.oas3.json b/rust/domains-client/openapi/domains.oas3.json index d2247c57..8dae00f3 100644 --- a/rust/domains-client/openapi/domains.oas3.json +++ b/rust/domains-client/openapi/domains.oas3.json @@ -4,7 +4,7 @@ "title": "Domain Lifecycle Management API", "version": "3.1.0", "x-visibility": "public", - "description": "The GoDaddy Domain Lifecycle Management API provides comprehensive capabilities\nfor discovering, registering, managing, renewing, transferring, and reselling\ndomain names. This is major version 3, designed for agent-first interactions\nwhile remaining fully usable by direct API clients and resellers.\n\n## Namespace\n\nAll paths are under `/v3/domains/`. The namespace is `domains` (the business capability);\nthe core entity collection is `/domain-names`.\n\n## Key Conventions\n\n**Quote/execute for commercial operations.** Every commercial mutation (register,\nrenew, transfer) requires a `quoteToken` minted by the corresponding quote\ncollection endpoint. Execution without a prior quote is structurally impossible.\nThe token locks the price, resolved settings, and required legal agreements for a\n10-minute TTL. Quote calls are free, read-only, and safe to call speculatively.\n\n**Async commercial operations.** `POST /registrations`, `POST /renewals`, and\n`POST /transfers` each return `202 Accepted` with the concrete entity body\n(`Registration`, `Renewal`, or `Transfer`) and a `Location` header. Poll\n`links[rel=self]` on the returned entity until status is `COMPLETED` or `FAILED`.\nEach concrete resource is also reachable via `GET /operations/{operationId}`;\nthe `operationId` is included in the entity for clients that prefer the abstract\nview. Non-commercial mutations return a `DomainOperation` body.\n\n**Entity-oriented resource model.** Domains are the core entity of this API\n(exposed as `/domain-names` in the path to distinguish the resource collection\nfrom the `domains` namespace prefix). Register, renew, and transfer are\ncommercial actions executed by `POST` to their corresponding top-level\nresource collections (`/registrations`, `/renewals`, `/transfers`); each\naccepts a prior quote and returns an async entity to poll until completion.\nFor commercial execute calls, the target domain is\nexpressed in the request body, not the path. Sub-resources (`contacts`,\n`nameservers`, `privacy`, `auto-renew`, `transfer-lock`, `records`) only exist\nin the context of a specific domain-name instance. The `/check-availability`\ncontroller accepts GET (single domain) or POST (1\u201350 domains) and carries no\npersistent identity.\n\n**Flat, two-level maximum.** No resource path goes deeper than\n`/{collection}/{id}/{sub-resource}` or `/{collection}/{id}/{sub-collection}/{id}`.\n\n**Reseller on-behalf-of.** Resellers pass `X-Shopper-Id`; all operations are\nthen scoped to that shopper. Absent the header, the authenticated entity's own\naccount is used.\n\n## Launch Scope (v3.0)\nStandard TLDs only. TLDs with eligibility requirements (.us, .ca, .eu) return\n`UNSUPPORTED_TLD` until Phase 2.\n", + "description": "The GoDaddy Domain Lifecycle Management API provides comprehensive capabilities\nfor discovering, registering, managing, renewing, transferring, and reselling\ndomain names. This is major version 3, designed for agent-first interactions\nwhile remaining fully usable by direct API clients and resellers.\n\n## Namespace\n\nAll paths are under `/v3/domains/`. The namespace is `domains` (the business capability);\nthe core entity collection is `/domain-names`.\n\n## Key Conventions\n\n**Quote/execute for commercial operations.** Every commercial mutation (register,\nrenew, transfer) requires a `quoteToken` minted by the corresponding quote\ncollection endpoint. Execution without a prior quote is structurally impossible.\nThe token locks the price, resolved settings, and required legal agreements for a\n10-minute TTL. Quote calls are free, read-only, and safe to call speculatively.\n\n**Async commercial operations.** `POST /registrations`, `POST /renewals`, and\n`POST /transfers` each return `202 Accepted` with the concrete entity body\n(`Registration`, `Renewal`, or `Transfer`) and a `Location` header. Poll\n`links[rel=self]` on the returned entity until status is `COMPLETED` or `FAILED`.\nEach concrete resource is also reachable via `GET /operations/{operationId}`;\nthe `operationId` is included in the entity for clients that prefer the abstract\nview. Non-commercial mutations return a `DomainOperation` body.\n\n**Entity-oriented resource model.** Domains are the core entity of this API\n(exposed as `/domain-names` in the path to distinguish the resource collection\nfrom the `domains` namespace prefix). Register, renew, and transfer are\ncommercial actions executed by `POST` to their corresponding top-level\nresource collections (`/registrations`, `/renewals`, `/transfers`); each\naccepts a prior quote and returns an async entity to poll until completion.\nFor commercial execute calls, the target domain is\nexpressed in the request body, not the path. Sub-resources (`contacts`,\n`nameservers`, `privacy`, `auto-renew`, `transfer-lock`, `records`) only exist\nin the context of a specific domain-name instance. The `/check-availability`\ncontroller accepts GET (single domain) or POST (1\u201325 domains) and carries no\npersistent identity.\n\n**Flat, two-level maximum.** No resource path goes deeper than\n`/{collection}/{id}/{sub-resource}` or `/{collection}/{id}/{sub-collection}/{id}`.\n\n**Reseller on-behalf-of.** Resellers pass `X-Shopper-Id`; all operations are\nthen scoped to that shopper. Absent the header, the authenticated entity's own\naccount is used.\n\n## Launch Scope (v3.0)\nStandard TLDs only. TLDs with eligibility requirements (.us, .ca, .eu) return\n`UNSUPPORTED_TLD` until Phase 2.\n", "contact": { "name": "GoDaddy Domains Platform", "x-slack-channel": "#domains_beplat_eng", @@ -13,7 +13,7 @@ }, "servers": [ { - "url": "https://api.godaddy.com", + "url": "https://api.ote-godaddy.com", "description": "Domains API host" } ], @@ -25,7 +25,7 @@ "tags": [ { "name": "Discovery", - "description": "Indicative, non-committing operations for finding and checking domains. Use suggestDomains for natural-language queries. For a single known domain, use getDomainAvailability (GET /check-availability); for 1\u201350 domains in one call, use checkAvailability (POST /check-availability). Both availability operations share the same check semantics and Availability result model; locked pricing is established only at quote time. Neither carries a persistent check identity.\n" + "description": "Indicative, non-committing operations for finding and checking domains. Use suggestDomains for natural-language queries. For a single known domain, use getDomainAvailability (GET /check-availability); for 1\u201325 domains in one call, use checkAvailability (POST /check-availability). Both availability operations share the same check semantics and Availability result model; locked pricing is established only at quote time. Neither carries a persistent check identity.\n" }, { "name": "Registration Quotes", @@ -323,7 +323,7 @@ "Discovery" ], "summary": "Check availability of one or more specific domains", - "description": "Batch controller for domain availability checking. Accepts 1\u201350 domain\nnames alongside optional check criteria (optimization mode, ISC pricing\ncode). Returns one Availability result per requested domain in input\norder inside `{ items: [...] }`. Domains that cannot be checked carry\nan `error` object on that item.\n\nFor a single domain, GET /check-availability (getDomainAvailability)\noffers the same check semantics and Availability result without a\nrequest body; the response is the lone item unwrapped.\n\nAvailability is best-effort indicative; the authoritative check is\nalways performed at quote time. This controller does not persist the\ncheck \u2014 there is no check identity or poll URL.\n", + "description": "Batch controller for domain availability checking. Accepts 1\u201325 domain\nnames alongside optional check criteria (optimization mode, ISC pricing\ncode). Returns one Availability result per requested domain in input\norder inside `{ items: [...] }`. Domains that cannot be checked carry\nan `error` object on that item.\n\nFor a single domain, GET /check-availability (getDomainAvailability)\noffers the same check semantics and Availability result without a\nrequest body; the response is the lone item unwrapped.\n\nAvailability is best-effort indicative; the authoritative check is\nalways performed at quote time. This controller does not persist the\ncheck \u2014 there is no check identity or poll URL.\n", "parameters": [ { "$ref": "#/components/parameters/xRequestId" @@ -715,6 +715,123 @@ } } }, + "/v3/domains/domain-names": { + "get": { + "operationId": "listDomains", + "tags": [ + "Domains" + ], + "summary": "List registered domains", + "description": "Returns a paginated collection of domain names owned by the authenticated account. Supports filtering by statuses and cursor-based pagination. The statuses and lifecycleGroups parameters are mutually exclusive; supplying both returns 400 Bad Request. An unrecognized value for statuses or lifecycleGroups returns 400 Bad Request.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + }, + { + "$ref": "#/components/parameters/pageToken" + }, + { + "$ref": "#/components/parameters/pageTokenDirection" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "description": "Maximum number of domains in the response. Defaults to 100 when omitted. Offset-based \"page\" parameter is not supported, only cursor-based \"pageToken\".\n", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 100 + } + }, + { + "name": "statuses", + "in": "query", + "required": false, + "description": "Filter results to domains with one or more lifecycle statuses. Supply multiple values as a single comma-separated list, e.g. `?statuses=ACTIVE,EXPIRED`. Multiple values are combined with logical OR \u2014 returns domains matching ANY of the specified statuses. See DomainStatus for accepted values (ACTIVE, EXPIRED, PENDING_REGISTRATION, etc.). Cannot be combined with the lifecycleGroups parameter. Use this for precise filtering on specific known status values; for coarse lifecycle phases, consider lifecycleGroups.\n", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "maxItems": 50, + "items": { + "type": "string" + } + }, + "example": [ + "ACTIVE", + "EXPIRED" + ] + }, + { + "name": "lifecycleGroups", + "in": "query", + "required": false, + "description": "Filter results to domains belonging to one or more status groups. Supply multiple values as a single comma-separated list, e.g. `?lifecycleGroups=REGISTERED,PENDING`. Multiple values are combined with logical OR. Cannot be combined with the statuses parameter. Use this for coarse lifecycle phases that remain stable as new statuses are added; for precise filtering, use statuses.\n", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DomainLifecycleGroup" + } + }, + "example": [ + "REGISTERED", + "PENDING" + ] + }, + { + "name": "updatedAfter", + "in": "query", + "required": false, + "description": "Return only domains last updated after this timestamp (exclusive). Must be a valid RFC 3339 date-time.\n", + "schema": { + "type": "string" + }, + "example": "2026-01-01T00:00:00Z" + }, + { + "name": "expiresBefore", + "in": "query", + "required": false, + "description": "Return only domains whose registration expires before this timestamp (exclusive). Must be a valid RFC 3339 date-time.\n", + "schema": { + "type": "string" + }, + "example": "2027-01-01T00:00:00Z" + } + ], + "security": [ + { + "oauth2": [ + "domains.domain:read" + ] + } + ], + "responses": { + "200": { + "description": "Paginated list of domains owned by the account.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DomainCollection" + } + } + } + } + } + } + }, "/v3/domains/domain-names/{domain-name}": { "get": { "operationId": "getDomain", @@ -1709,6 +1826,29 @@ }, "example": true }, + "pageToken": { + "name": "pageToken", + "in": "query", + "description": "Opaque cursor from the links[rel=next or rel=prev] href of the previous page. When present, the response begins immediately after the item that produced the token. Omit to start from the beginning of the collection.\n", + "required": false, + "schema": { + "type": "string" + }, + "example": "eyJkb21haW4iOiJleGFtcGxlLmNvbSJ9" + }, + "pageTokenDirection": { + "name": "pageTokenDirection", + "in": "query", + "description": "Optional token direction when `pageToken` is set; ignored otherwise.\n", + "required": false, + "schema": { + "type": "string", + "enum": [ + "backward", + "forward" + ] + } + }, "fields": { "name": "fields", "in": "query", @@ -1947,7 +2087,7 @@ }, "AvailabilityCheckCriteria": { "title": "Availability Check Criteria", - "description": "Criteria for an availability check. Specifies 1\u201350 domain names and optional parameters that influence how the check is performed. This controller does not persist the check; there is no check identity or poll URL.\n", + "description": "Criteria for an availability check. Specifies 1\u201325 domain names and optional parameters that influence how the check is performed. This controller does not persist the check; there is no check identity or poll URL.\n", "type": "object", "required": [ "domains" @@ -1956,11 +2096,11 @@ "domains": { "type": "array", "minItems": 1, - "maxItems": 50, + "maxItems": 25, "items": { "type": "string" }, - "description": "List of 1\u201350 domain names to check, in punycode A-label form for IDNs.\n", + "description": "List of 1\u201325 domain names to check, in punycode A-label form for IDNs.\n", "example": [ "example.com", "example.net" @@ -2019,6 +2159,23 @@ "$ref": "#/components/schemas/ConsentActor" } ] + }, + "acknowledgedFees": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/Fee" + }, + "description": "Fees the customer explicitly acknowledged before executing the purchase. When the preceding quote's fees array is non-empty, this field must be present and contain the same entries verbatim (same types, amounts, and currencies). The server validates this against the locked quote token; a mismatch is rejected with 422 quote_mismatch. Omit when the quote carries no purchase fees (standard REGISTRY registration).\n", + "example": [ + { + "type": "ONE_TIME_PREMIUM_DOMAIN_PURCHASE", + "fee": { + "value": 390000, + "currencyCode": "USD" + } + } + ] } } }, @@ -2293,6 +2450,35 @@ } } }, + "DomainLifecycleGroup": { + "title": "Domain Lifecycle Group", + "description": "A logical grouping of domain lifecycle statuses for filtering purposes. PENDING \u2014 domain setup is in progress (e.g. PENDING_REGISTRATION). REGISTERED \u2014 domain is actively registered and operational (e.g. ACTIVE, PARKED). PENDING_TERMINAL \u2014 domain is approaching a terminal state (e.g. PENDING_TRANSFER, EXPIRED). TERMINAL \u2014 domain has reached a terminal state and is no longer manageable (e.g. CANCELLED, DELETED_REDEEMABLE, TRANSFERRED, REPOSSESSED).\n", + "type": "string" + }, + "DomainCollection": { + "title": "DomainCollection", + "description": "A paginated collection of registered domains owned by the authenticated account. Supports filtering by status and cursor-based pagination.\n", + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "Domains for the current page.", + "items": { + "$ref": "#/components/schemas/Domain" + }, + "nullable": true + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/link-description" + }, + "description": "HATEOAS pagination links. May include rel=self (current page), rel=prev is present when more items are available before the current page. and rel=next is present when more items are available beyond the current page.\n", + "readOnly": true, + "nullable": true + } + } + }, "DomainOperation": { "title": "Domain Operation", "description": "The abstract operation envelope for all domain mutations, returned by the universal GET /operations/{operationId} endpoint. Concrete specializations \u2014 Registration, Renewal, and Transfer \u2014 are returned directly by their respective POST endpoints and carry the same operationId. Developers who do not need the abstract view can poll the concrete resource (GET /registrations/{id}, etc.) and ignore this type entirely.\nOperation IDs are unique across all concrete types, so either poll path works for any given operation.\nAsync state machine:\n status tracks where the operation is in its lifecycle. Non-terminal values\n (CONFIRMED, EXECUTING) are transient \u2014 poll until a terminal value is reached.\n result and error are mutually exclusive terminal payloads:\n COMPLETED \u2014 operation succeeded; result contains the final outcome data.\n FAILED \u2014 operation terminated; error contains failure detail.\n Neither result nor error is present while status is non-terminal.\n", @@ -2408,6 +2594,30 @@ } } }, + "Fee": { + "title": "Fee", + "description": "Fees charged at the time of purchase, separate from the registration price. The type identifies the nature of the charge.\n", + "type": "object", + "properties": { + "type": { + "description": "Identifies the nature of this charge. Determines how the fee is categorized in pricing breakdowns and receipts.\n", + "example": "ONE_TIME_PREMIUM_DOMAIN_PURCHASE", + "allOf": [ + { + "$ref": "#/components/schemas/FeeType" + } + ] + }, + "fee": { + "description": "The monetary amount of this fee.", + "allOf": [ + { + "$ref": "#/components/schemas/simple-money" + } + ] + } + } + }, "Registration": { "title": "Registration", "description": "A domain registration entity created when a POST /registrations request is accepted. Registrations are a top-level resource with their own stable registrationId; the domain relationship is captured in the representation.\nOn POST /registrations, supply the writable fields (domain, period, quoteToken, consent, and optionally profileId/profile). The server returns the full Registration with readOnly fields populated. Poll links[rel=self] until status reaches COMPLETED or FAILED. The same resource is also reachable via GET /operations/{operationId} for clients operating at the abstract level; operationId is included in the representation for that purpose.\n", @@ -2540,6 +2750,14 @@ }, "description": "HATEOAS link relations for this registration. rel=self \u2014 the canonical URL for this registration record. rel=domain \u2014 the registered domain-name resource once the registration is complete.\n", "readOnly": true + }, + "fees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Fee" + }, + "description": "Fees charged at the time of purchase, in addition to the registration price. Present when purchase fees were incurred. Same fees as RegistrationQuote.fees (the selected term's TermPrice.fees) from the preceding quote.\n", + "readOnly": true } } }, @@ -2652,6 +2870,23 @@ "type": "boolean", "description": "Whether executing this quote is irreversible once accepted. Use to calibrate the explicitness of any confirmation step presented before execute.\n", "example": false + }, + "fees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Fee" + }, + "description": "Fees charged at the time of purchase, in addition to the registration price. Present when the domain incurs purchase fees beyond the base registration cost. These are the same fees as TermPrice.fees for the selected term (this quote's period) \u2014 not a separate term-independent fee list and not additional to term-level fees. Execute cost is price plus this array. Mirrored on Registration.fees after execute.\n", + "nullable": true + }, + "inventory": { + "description": "The inventory type of the quoted domain. Present when available is true.\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/InventoryType" + } + ] } } }, @@ -2772,6 +3007,31 @@ "$ref": "#/components/schemas/simple-money" } ] + }, + "fees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Fee" + }, + "description": "Fees charged at the time of purchase for this term, in addition to price. Present when the domain incurs purchase fees beyond the base registration cost. The full cost of choosing this term is price plus these fees.\n", + "nullable": true + }, + "firstTermPrice": { + "description": "Discounted price of only the first term unit within this multi-unit period. Typically omitted when period is 1 (where it equals price). Present when a first-term-only promotion applies to a multi-unit period.\n", + "example": { + "currencyCode": "USD", + "value": 1199 + }, + "allOf": [ + { + "$ref": "#/components/schemas/simple-money" + } + ] + }, + "recommended": { + "type": "boolean", + "description": "When true, this term is the best-value option based on active discounts or promotions. At most one entry in the prices array carries this flag. Absent (not false) when no recommendation applies.\n", + "example": true } } }, @@ -2829,6 +3089,14 @@ "PREMIUM" ] }, + "FeeType": { + "title": "Fee Type", + "description": "The type of a fee charged at the time of purchase. ONE_TIME_PREMIUM_DOMAIN_PURCHASE \u2014 one-time acquisition cost charged by the aftermarket marketplace when purchasing a premium domain.\n", + "type": "string", + "enum": [ + "ONE_TIME_PREMIUM_DOMAIN_PURCHASE" + ] + }, "error-details": { "title": "Error Details", "type": "object", diff --git a/rust/domains-client/openapi/swagger_domains.v3.yaml b/rust/domains-client/openapi/swagger_domains.v3.yaml index 09b45aac..ce4cab64 100644 --- a/rust/domains-client/openapi/swagger_domains.v3.yaml +++ b/rust/domains-client/openapi/swagger_domains.v3.yaml @@ -69,7 +69,7 @@ info: in the context of a specific domain-name instance. The `/check-availability` - controller accepts GET (single domain) or POST (1–50 domains) and carries no + controller accepts GET (single domain) or POST (1–25 domains) and carries no persistent identity. @@ -114,7 +114,7 @@ security: tags: - name: Discovery description: 'Indicative, non-committing operations for finding and checking domains. Use suggestDomains for natural-language - queries. For a single known domain, use getDomainAvailability (GET /check-availability); for 1–50 domains in one call, + queries. For a single known domain, use getDomainAvailability (GET /check-availability); for 1–25 domains in one call, use checkAvailability (POST /check-availability). Both availability operations share the same check semantics and Availability result model; locked pricing is established only at quote time. Neither carries a persistent check identity. @@ -392,7 +392,7 @@ paths: tags: - Discovery summary: Check availability of one or more specific domains - description: 'Batch controller for domain availability checking. Accepts 1–50 domain + description: 'Batch controller for domain availability checking. Accepts 1–25 domain names alongside optional check criteria (optimization mode, ISC pricing @@ -766,6 +766,124 @@ paths: $ref: '#/components/responses/404' '429': $ref: '#/components/responses/429' + /domain-names: + get: + operationId: listDomains + tags: + - Domains + summary: List registered domains + description: 'Returns a paginated collection of domain names owned by the authenticated account. Supports filtering + by statuses and cursor-based pagination. The statuses and lifecycleGroups parameters are mutually exclusive; supplying + both returns 400 Bad Request. An unrecognized value for statuses or lifecycleGroups returns 400 Bad Request. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + - $ref: '#/components/parameters/pageToken' + - $ref: '#/components/parameters/pageTokenDirection' + - name: pageSize + in: query + required: false + description: 'Maximum number of domains in the response. Defaults to 100 when omitted. Offset-based "page" parameter + is not supported, only cursor-based "pageToken". + + ' + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + - name: statuses + in: query + required: false + description: 'Filter results to domains with one or more lifecycle statuses. Supply multiple values as a single + comma-separated list, e.g. `?statuses=ACTIVE,EXPIRED`. Multiple values are combined with logical OR — returns + domains matching ANY of the specified statuses. See DomainStatus for accepted values (ACTIVE, EXPIRED, + PENDING_REGISTRATION, etc.). Cannot be combined with the lifecycleGroups parameter. Use this for precise filtering + on specific known status values; for coarse lifecycle phases, consider lifecycleGroups. + + ' + # `items` is a plain string, not `$ref: DomainStatus` — progenitor + # always seq-serializes an array-of-enum setter argument as repeated + # `statuses=` pairs regardless of this `explode: false`, and unlike + # `DomainLifecycleGroup` (a bare string schema), `DomainStatus` is a + # strict enum that can't hold a pre-joined "A,B" value. A plain string + # array lets the CLI comma-join client-side (see `comma_joined`, + # DEVEX-882) while still validating against `DomainStatus` before + # sending. Mirrors `tlds`/`sources` above. + style: form + explode: false + schema: + type: array + maxItems: 50 + items: + type: string + example: + - ACTIVE + - EXPIRED + - name: lifecycleGroups + in: query + required: false + description: 'Filter results to domains belonging to one or more status groups. Supply multiple values as a single + comma-separated list, e.g. `?lifecycleGroups=REGISTERED,PENDING`. Multiple values are combined with logical OR. + Cannot be combined with the statuses parameter. Use this for coarse lifecycle phases that remain stable as new + statuses are added; for precise filtering, use statuses. + + ' + style: form + explode: false + schema: + type: array + items: + $ref: '#/components/schemas/DomainLifecycleGroup' + example: + - REGISTERED + - PENDING + - name: updatedAfter + in: query + required: false + description: 'Return only domains last updated after this timestamp (exclusive). Must be a valid RFC 3339 date-time. + + ' + schema: + type: string + format: date-time + example: '2026-01-01T00:00:00Z' + - name: expiresBefore + in: query + required: false + description: 'Return only domains whose registration expires before this timestamp (exclusive). Must be a valid RFC + 3339 date-time. + + ' + schema: + type: string + format: date-time + example: '2027-01-01T00:00:00Z' + security: + - oauth2: + - domains.domain:read + responses: + '200': + description: Paginated list of domains owned by the account. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/DomainCollection' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '422': + $ref: '#/components/responses/422' + '429': + $ref: '#/components/responses/429' /domain-names/{domain-name}: get: operationId: getDomain @@ -1308,6 +1426,29 @@ components: type: boolean default: false example: true + pageToken: + name: pageToken + in: query + description: 'Opaque cursor from the links[rel=next or rel=prev] href of the previous page. When present, the response + begins immediately after the item that produced the token. Omit to start from the beginning of the collection. + + ' + required: false + schema: + type: string + example: eyJkb21haW4iOiJleGFtcGxlLmNvbSJ9 + pageTokenDirection: + name: pageTokenDirection + in: query + description: 'Optional token direction when `pageToken` is set; ignored otherwise. + + ' + required: false + schema: + type: string + enum: + - backward + - forward fields: name: fields in: query @@ -1517,7 +1658,7 @@ components: - $ref: '#/components/schemas/error' AvailabilityCheckCriteria: title: Availability Check Criteria - description: 'Criteria for an availability check. Specifies 1–50 domain names and optional parameters that influence + description: 'Criteria for an availability check. Specifies 1–25 domain names and optional parameters that influence how the check is performed. This controller does not persist the check; there is no check identity or poll URL. ' @@ -1528,10 +1669,10 @@ components: domains: type: array minItems: 1 - maxItems: 50 + maxItems: 25 items: type: string - description: 'List of 1–50 domain names to check, in punycode A-label form for IDNs. + description: 'List of 1–25 domain names to check, in punycode A-label form for IDNs. ' example: @@ -1590,6 +1731,22 @@ components: readOnly: true allOf: - $ref: '#/components/schemas/ConsentActor' + acknowledgedFees: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Fee' + description: 'Fees the customer explicitly acknowledged before executing the purchase. When the preceding quote''s + fees array is non-empty, this field must be present and contain the same entries verbatim (same types, amounts, + and currencies). The server validates this against the locked quote token; a mismatch is rejected with 422 quote_mismatch. + Omit when the quote carries no purchase fees (standard REGISTRY registration). + + ' + example: + - type: ONE_TIME_PREMIUM_DOMAIN_PURCHASE + fee: + value: 390000 + currencyCode: USD ConsentActor: title: Consent Actor description: 'Identifies who gave consent and how it was transmitted. One uniform schema for all actor types. Populated @@ -1909,6 +2066,41 @@ components: — the domain''s WHOIS contact records. rel=nameservers — the domain''s authoritative nameservers. rel=dns-records — the domain''s DNS records managed by GoDaddy. + ' + readOnly: true + DomainLifecycleGroup: + title: Domain Lifecycle Group + description: 'A logical grouping of domain lifecycle statuses for filtering purposes. PENDING — domain setup is in progress + (e.g. PENDING_REGISTRATION). REGISTERED — domain is actively registered and operational (e.g. ACTIVE, PARKED). PENDING_TERMINAL + — domain is approaching a terminal state (e.g. PENDING_TRANSFER, EXPIRED). TERMINAL — domain has reached a terminal + state and is no longer manageable (e.g. CANCELLED, DELETED_REDEEMABLE, TRANSFERRED, REPOSSESSED). + + ' + type: string + DomainCollection: + title: DomainCollection + description: 'A paginated collection of registered domains owned by the authenticated account. Supports filtering by + status and cursor-based pagination. + + ' + type: object + required: + - items + - links + properties: + items: + type: array + description: Domains for the current page. + items: + $ref: '#/components/schemas/Domain' + links: + type: array + items: + $ref: '#/components/schemas/link-description' + description: 'HATEOAS pagination links. May include rel=self (current page), rel=prev is present when more items + are available before the current page. and rel=next is present when more items are available beyond the current + page. + ' readOnly: true DomainOperation: @@ -2027,6 +2219,29 @@ components: - $ref: '#/components/schemas/date-time' Error: $ref: '#/components/schemas/error' + Fee: + title: Fee + description: 'Fees charged at the time of purchase, separate from the registration price. The type identifies the nature + of the charge. + + ' + type: object + required: + - type + - fee + properties: + type: + description: 'Identifies the nature of this charge. Determines how the fee is categorized in pricing breakdowns + and receipts. + + ' + example: ONE_TIME_PREMIUM_DOMAIN_PURCHASE + allOf: + - $ref: '#/components/schemas/FeeType' + fee: + description: The monetary amount of this fee. + allOf: + - $ref: '#/components/schemas/simple-money' Registration: title: Registration description: 'A domain registration entity created when a POST /registrations request is accepted. Registrations are @@ -2148,6 +2363,16 @@ components: description: 'HATEOAS link relations for this registration. rel=self — the canonical URL for this registration record. rel=domain — the registered domain-name resource once the registration is complete. + ' + readOnly: true + fees: + type: array + items: + $ref: '#/components/schemas/Fee' + description: 'Fees charged at the time of purchase, in addition to the registration price. Present when purchase + fees were incurred. Same fees as RegistrationQuote.fees (the selected term''s TermPrice.fees) from the preceding + quote. + ' readOnly: true InlineRegistrationProfile: @@ -2280,6 +2505,23 @@ components: ' example: false + fees: + type: array + items: + $ref: '#/components/schemas/Fee' + description: 'Fees charged at the time of purchase, in addition to the registration price. Present when the domain + incurs purchase fees beyond the base registration cost. These are the same fees as TermPrice.fees for the selected + term (this quote''s period) — not a separate term-independent fee list and not additional to term-level fees. + Execute cost is price plus this array. Mirrored on Registration.fees after execute. + + ' + inventory: + description: 'The inventory type of the quoted domain. Present when available is true. + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/InventoryType' ResolvedSettings: title: Resolved Settings description: 'A preview of the effective settings that will be applied if the associated quote is executed. Returned @@ -2407,6 +2649,32 @@ components: ' allOf: - $ref: '#/components/schemas/simple-money' + fees: + type: array + items: + $ref: '#/components/schemas/Fee' + description: 'Fees charged at the time of purchase for this term, in addition to price. Present when the domain + incurs purchase fees beyond the base registration cost. The full cost of choosing this term is price plus these + fees. + + ' + firstTermPrice: + description: 'Discounted price of only the first term unit within this multi-unit period. Typically omitted when + period is 1 (where it equals price). Present when a first-term-only promotion applies to a multi-unit period. + + ' + example: + currencyCode: USD + value: 1199 + allOf: + - $ref: '#/components/schemas/simple-money' + recommended: + type: boolean + description: 'When true, this term is the best-value option based on active discounts or promotions. At most one + entry in the prices array carries this flag. Absent (not false) when no recommendation applies. + + ' + example: true uuid: description: A universally unique identifier (UUID) in [RFC-4122 format](https://tools.ietf.org/html/rfc4122). type: string @@ -2466,6 +2734,15 @@ components: - REGISTRY - REGISTRY_PREMIUM - PREMIUM + FeeType: + title: Fee Type + description: 'The type of a fee charged at the time of purchase. ONE_TIME_PREMIUM_DOMAIN_PURCHASE — one-time acquisition + cost charged by the aftermarket marketplace when purchasing a premium domain. + + ' + type: string + enum: + - ONE_TIME_PREMIUM_DOMAIN_PURCHASE error-details: title: Error Details type: object diff --git a/rust/domains-client/scripts/regenerate-spec.sh b/rust/domains-client/scripts/regenerate-spec.sh index d4934872..50fe6638 100755 --- a/rust/domains-client/scripts/regenerate-spec.sh +++ b/rust/domains-client/scripts/regenerate-spec.sh @@ -34,9 +34,22 @@ trimmed_v1="$openapi_dir/.swagger_domains.trimmed.v1.json" v1_oas3="$openapi_dir/.domains.v1.oas3.json" oas3="$openapi_dir/domains.oas3.json" -# The document host. base_url is overridden at runtime by the CLI per environment -# (environments::resolve_domains), so this is cosmetic — keep it prod-canonical, -# consistent with every other embedded catalog domain's baseUrl. +# The document host. `gddy domain`/`dns` commands never read this — their base +# URL is resolved at runtime via `environments::resolve_domains` — but +# `generate-api-catalog` does: `resolve_catalog_base_url` treats this value as +# the prod URL and derives every other environment from it by host +# substitution (see its `prod_base_url` parameter and +# `resolve_catalog_base_url_returns_prod_unchanged`/`_applies_convention_for_non_prod` +# in `environments/mod.rs`). Keep it prod-canonical, consistent with every +# other embedded catalog domain's baseUrl, so that derivation stays correct. +# +# TEMPORARY: the currently-checked-in `../openapi/domains.oas3.json` on the +# cli-v3-domain-list branch has this set to the OTE host instead, because that +# branch's spec syncs were done by hand against the v3 API's test environment +# (v3 isn't in prod yet) rather than by running this script. That branch is +# preview-only and not meant to merge until v3 ships to prod and the spec is +# re-synced properly — re-running this script at that point will restore the +# prod-canonical value below. host="https://api.godaddy.com" echo "==> Downloading upstream Swagger 2.0 spec" diff --git a/rust/domains-client/src/lib.rs b/rust/domains-client/src/lib.rs index b0775604..87bd9b49 100644 --- a/rust/domains-client/src/lib.rs +++ b/rust/domains-client/src/lib.rs @@ -364,10 +364,12 @@ mod tests { type_: types::ConsentActorType::Direct, }), agreement_types: vec![types::AgreementType::ApiDpa], + acknowledged_fees: vec![], }, created_at: None, domain: "example.com".to_string(), expires_at: None, + fees: vec![], links: vec![], operation_id: None, order_id: None, diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index a2530dfa..9fe91938 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -7,43 +7,86 @@ use serde_json::json; use domains_client::types; -use super::common::{ - api_error, format_money, headline_price, make_client, period_label, validate_domain_name, -}; +use super::common::{api_error, format_money, make_client, period_label, validate_domain_name}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; // The handler emits a transformed shape (formatted price/currency strings from -// SimpleMoney, a single headline term) rather than the raw `types::Availability`, -// so `--schema`/help metadata is declared to match what's actually returned. +// SimpleMoney, one entry per registration term) rather than the raw +// `types::Availability`, so `--schema`/help metadata is declared to match what's +// actually returned. output_schema!(DomainAvailableResult { "domain": "string"; "available": "bool"; "definitive": "bool"; - // Present only when the API returns a headline price for the term. - "price": "string", optional; + // Present only when the API returns priced terms at all. "currency": "string", optional; - "renewalPrice": "string", optional; - "period": "number", optional; - "periodLabel": "string", optional; + "terms": "[]object", optional; }); -/// `period` alone would render in the default table as a bare number, so swap -/// it for the unit-bearing `periodLabel` there; `period` stays available (via -/// `--fields all`/explicit selection) for scripting against `--output json`. fn view_columns() -> Vec { vec![ TableColumn::new("domain", "Domain"), TableColumn::new("available", "Available"), TableColumn::new("definitive", "Definitive"), - TableColumn::new("price", "Price"), - TableColumn::new("renewalPrice", "Renewal Price"), TableColumn::new("currency", "Currency"), - TableColumn::new("periodLabel", "Period"), + TableColumn::new("terms", "Terms").nested(vec![ + TableColumn::new("periodLabel", "Period"), + TableColumn::new("price", "Price"), + TableColumn::new("firstTermPrice", "First-Term Price"), + TableColumn::new("renewalPrice", "Renewal Price"), + ]), ] } +/// Render one `TermPrice` entry as `{period, periodLabel, price, renewalPrice, +/// firstTermPrice}`. `None` when the term carries no period (never happens in +/// practice, but keeps every emitted object schema-conformant). Currency isn't +/// repeated per term — it's virtually always the same across a domain's terms, +/// so it's surfaced once at the top level instead of as a redundant column in +/// every row. `TermPrice.recommended` (a hint for web UIs on which term to +/// feature) is intentionally not surfaced — it read as a confusing, unexplained +/// flag in CLI output. +fn term_to_json(term: &types::TermPrice) -> Option { + let period = term.period?; + let mut obj = json!({ + "period": period.get(), + "periodLabel": period_label(period.get()), + }); + if let Some(price) = term.price.as_ref().and_then(format_money) { + obj["price"] = json!(price); + } + if let Some(renewal) = term.renewal_price.as_ref().and_then(format_money) { + obj["renewalPrice"] = json!(renewal); + } + if let Some(first_term) = term.first_term_price.as_ref().and_then(format_money) { + obj["firstTermPrice"] = json!(first_term); + } + Some(obj) +} + +/// The currency code shared by a domain's priced terms (all terms use the +/// same currency in practice, so one top-level field covers every row in +/// `terms`) — the first `currencyCode` found across every term's price, +/// renewal price, and first-term price. Keeps searching past a money value +/// that has no `currencyCode` (itself optional) rather than stopping at the +/// first price-like value regardless of whether it actually carries one. +fn shared_currency(prices: &[types::TermPrice]) -> Option { + prices + .iter() + .flat_map(|t| { + [ + t.price.as_ref(), + t.renewal_price.as_ref(), + t.first_term_price.as_ref(), + ] + }) + .flatten() + .find_map(|m| m.currency_code.as_ref()) + .map(|c| c.to_string()) +} + #[derive(Debug, Clone, clap::Args)] struct AvailableArgs { /// Domain name to check (e.g. example.com). @@ -55,21 +98,19 @@ struct AvailableArgs { check_type: Option, } -/// The headline price for an availability/quote: the entry for a 1-year term if -/// present, else the first listed term. pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("available", "Check whether a domain is available") .with_long( "Check whether a domain can be registered, and at what price. \ --check-type fast trades accuracy for speed; full is authoritative \ - (the `definitive` field tells you which you got).", + (the `definitive` field tells you which you got). Lists every \ + registration term the API prices (1 year, 2 years, …), including \ + any first-term promotion.", ) .with_system("domain") .with_tier(Tier::Read) - .with_default_fields( - "domain,available,definitive,price,renewalPrice,currency,period,periodLabel", - ) + .with_default_fields("domain,available,definitive,currency,terms") .with_output_schema::() .with_view(view_columns()) .with_scopes(&[DOMAINS_READ]), @@ -102,22 +143,17 @@ pub(super) fn command() -> RuntimeCommandSpec { "definitive": body.definitive.unwrap_or(false), }); let prices = body.prices.unwrap_or_default(); - if let Some(term) = headline_price(&prices) { - if let Some(price) = term.price.as_ref().and_then(format_money) { - result["price"] = json!(price); - // Only emit `currency` when a code is present — never a JSON - // null (schema declares it optional string; null leaks into - // table output). - if let Some(code) = term.price.as_ref().and_then(|m| m.currency_code.as_ref()) { - result["currency"] = json!(code.to_string()); - } - } - if let Some(renewal) = term.renewal_price.as_ref().and_then(format_money) { - result["renewalPrice"] = json!(renewal); - } - if let Some(period) = term.period { - result["period"] = json!(period.get()); - result["periodLabel"] = json!(period_label(period.get())); + let terms: Vec = prices.iter().filter_map(term_to_json).collect(); + // `terms` is emitted whenever any priced term exists, independent + // of whether a currency could be derived (`currencyCode` is + // itself optional per the API, so pricing with no currency is + // valid and must not suppress the terms themselves). `currency` + // is the one gated on `terms` being non-empty, so it never + // appears with no corresponding rows to interpret it against. + if !terms.is_empty() { + result["terms"] = json!(terms); + if let Some(currency) = shared_currency(&prices) { + result["currency"] = json!(currency); } } @@ -141,33 +177,90 @@ pub(super) fn command() -> RuntimeCommandSpec { #[cfg(test)] mod tests { - use super::{command, view_columns}; + use super::{command, shared_currency, view_columns}; + use domains_client::types; use serde_json::json; #[test] - fn default_fields_includes_renewal_price() { - // Regression for GDDEVPLAT-133: `renewalPrice` was computed and present - // in `--output json` but silently dropped from the default table view. - // An exact field match (not a substring check) so a future field like - // `renewalPrice1Year` can't produce a false pass here. + fn default_fields_includes_terms() { let fields = command().spec.default_fields.expect("default fields set"); - assert!(fields.split(',').any(|f| f == "renewalPrice"), "{fields}"); + assert!(fields.split(',').any(|f| f == "terms"), "{fields}"); + } + + #[test] + fn shared_currency_falls_back_to_first_term_price_when_others_are_absent() { + // A term with only a first-term promotion (no price/renewalPrice) must + // still yield a currency — otherwise `firstTermPrice` values render + // with no top-level `currency` to interpret them against. + let term = types::TermPrice { + first_term_price: Some(types::SimpleMoney { + value: Some(250), + currency_code: Some(types::CurrencyCode("USD".to_string())), + }), + ..Default::default() + }; + assert_eq!(shared_currency(&[term]), Some("USD".to_string())); + } + + #[test] + fn shared_currency_keeps_searching_past_a_price_with_no_currency_code() { + // currencyCode is itself optional per the API. The first term's + // price having none must not short-circuit the search — a later + // term (or a later field on the same term) may still carry one. + let no_currency = types::TermPrice { + price: Some(types::SimpleMoney { + value: Some(999), + currency_code: None, + }), + ..Default::default() + }; + let has_currency = types::TermPrice { + price: Some(types::SimpleMoney { + value: Some(1999), + currency_code: Some(types::CurrencyCode("USD".to_string())), + }), + ..Default::default() + }; + assert_eq!( + shared_currency(&[no_currency, has_currency]), + Some("USD".to_string()) + ); } + /// Proves `view_columns()` renders `terms` shaped like what the handler + /// actually emits (confirmed by inspection — `term_to_json` builds each + /// entry from a `json!` literal with these exact keys) as a nested table, + /// including the period's human label — a mismatch would silently drop + /// fields from `--fields all` output. #[test] - fn period_renders_with_its_unit_in_the_default_table() { - // The bare `period` number ("1") read as ambiguous; the table must show - // `periodLabel` ("1 year") instead, while `period` itself stays numeric - // in the payload for scripting against `--output json`. + fn terms_render_as_a_nested_table_with_period_label() { let available = json!({ "domain": "example.com", "available": true, "definitive": true, - "period": 1, - "periodLabel": "1 year", + "currency": "USD", + "terms": [ + { + "period": 1, + "periodLabel": "1 year", + "price": "10.49", + "renewalPrice": "22.99", + }, + { + "period": 2, + "periodLabel": "2 years", + "price": "25.48", + "renewalPrice": "45.98", + "firstTermPrice": "2.50", + }, + ], }); let envelope = cli_engine::Envelope::success(available, "domain"); let rendered = cli_engine::render_human_with_view(&envelope, Some(&view_columns()), ""); + assert!(rendered.contains("Terms:"), "{rendered}"); assert!(rendered.contains("1 year"), "{rendered}"); + assert!(rendered.contains("2 years"), "{rendered}"); + assert!(rendered.contains("2.50"), "{rendered}"); + assert!(!rendered.contains("Recommended"), "{rendered}"); } } diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index ca009a45..6b2db862 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -80,17 +80,6 @@ pub(super) fn format_money(money: &types::SimpleMoney) -> Option { )) } -/// The headline price among per-term prices: the 1-year term if present, else the -/// first listed term. Used by `available`, which surfaces a single indicative -/// price from a `TermPrice` array. -pub(super) fn headline_price(prices: &[types::TermPrice]) -> Option<&types::TermPrice> { - let one_year = std::num::NonZeroU64::new(1); - prices - .iter() - .find(|p| p.period == one_year) - .or_else(|| prices.first()) -} - /// The entry for a specific year-term period (1, 2, …), or `None` when that term /// isn't in the list. Used by `suggest` to flatten multiple terms into scalar /// per-period fields (e.g. `price1Year`, `price2Year`). diff --git a/rust/src/domain/list.rs b/rust/src/domain/list.rs index b4b35d02..61581e8d 100644 --- a/rust/src/domain/list.rs +++ b/rust/src/domain/list.rs @@ -1,4 +1,4 @@ -//! `gddy domain list` — list the domains in the account (v1). +//! `gddy domain list` — list the domains in the account (v3). use cli_engine::{ CliCoreError, CommandResult, CommandSpec, NextActionParam, PaginationConfig, Result, @@ -8,26 +8,49 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, make_client}; +use super::common::{api_error, comma_joined, make_client}; use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; -/// Validate `--status` values case-insensitively against the generated -/// `ListStatusesItem` enum (the v1 list API's `DomainStatus` set, e.g. `ACTIVE`). -fn parse_statuses(raw: &[String]) -> Result> { +/// Lifecycle groups that make up GoDaddy's default "visible" domain view — +/// every group except `TERMINAL` (cancelled, confiscated, transferred out, +/// deleted-redeemable, etc.). `DomainLifecycleGroup` has no generated enum +/// (the spec declares it a bare string), so these are validated server-side. +const DEFAULT_VISIBLE_GROUPS: [&str; 3] = ["PENDING", "REGISTERED", "PENDING_TERMINAL"]; + +/// v3's max `pageSize` (the spec's `maximum: 200`). Requesting it on every +/// page minimizes round trips against an API observed to rate-limit as +/// tightly as 5 requests per period. +const MAX_PAGE_SIZE: u64 = 200; + +/// Defensive cap on pages fetched for one invocation. No real account should +/// ever approach `MAX_PAGE_SIZE * MAX_PAGES` (10,000) domains; hitting this +/// is treated as a bug (a malformed or looping `next` link) rather than +/// silently returned as if it were the complete list. +const MAX_PAGES: usize = 50; + +/// Validate each `--status` value case-insensitively against the generated +/// `DomainStatus` enum (e.g. `ACTIVE`), returning the canonical uppercase +/// wire form. The `statuses` query parameter is typed as plain strings (not +/// `$ref: DomainStatus`) purely so it can be comma-joined client-side (see +/// `comma_joined`) — this still rejects unknown values before the request +/// goes out, same as if the array held real `DomainStatus`es. +fn parse_statuses(raw: &[String]) -> Result> { raw.iter() .map(|s| { - types::ListStatusesItem::try_from(s.to_uppercase().as_str()) + let upper = s.to_uppercase(); + types::DomainStatus::try_from(upper.as_str()) + .map(|_| upper) .map_err(|_| CliCoreError::message(format!("invalid --status {s:?}"))) }) .collect() } -/// Whether the request should be scoped to the `VISIBLE` status group — -/// GoDaddy's default view that hides cancelled/confiscated/other -/// non-visible domains. Skipped when the caller passed an explicit -/// `--status` filter or asked to see hidden domains via `--show-hidden`. -fn wants_visible_only(statuses: &[types::ListStatusesItem], show_hidden: bool) -> bool { +/// Whether the request should be scoped to the default "visible" lifecycle +/// groups — GoDaddy's default view that hides cancelled/confiscated/other +/// terminal domains. Skipped when the caller passed an explicit `--status` +/// filter or asked to see hidden domains via `--show-hidden`. +fn wants_visible_only(statuses: &[String], show_hidden: bool) -> bool { statuses.is_empty() && !show_hidden } @@ -42,19 +65,159 @@ struct ListArgs { show_hidden: bool, } +/// The result of looking for a `rel=next` link in a page's `links`. Per the +/// spec, `rel=next` is present *only when more items are actually +/// available* — so its presence is a guarantee, not a hint. That's why a +/// present-but-unparseable link is kept distinct from "no next link at +/// all": the former means the API says there's more data and this CLI +/// failed to reach it (a bug worth erroring on), while the latter means +/// pagination has genuinely finished. +#[derive(Debug, PartialEq)] +enum NextPage { + /// No `rel=next` link — every page has been fetched. + Done, + /// A `rel=next` link was present and its `pageToken` (+ optional + /// direction) was extracted. + Token(String, Option), + /// A `rel=next` link was present, but this CLI couldn't extract a + /// `pageToken` from it — a link shape this build doesn't recognize. + Unparseable, +} + +/// Classify a `DomainCollection`'s `links` per [`NextPage`]. Parses only the +/// query string (everything after the first `?`) rather than the whole +/// `href` as a URL — the spec's own link examples use relative paths (e.g. +/// `/v3/domains/domain-names?...`), which `url::Url::parse` rejects outright +/// since a relative reference isn't a valid standalone URL; splitting on `?` +/// works for both relative and absolute `href`s. +fn next_page_token(links: &[types::LinkDescription]) -> NextPage { + let Some(next) = links.iter().find(|l| l.rel.as_deref() == Some("next")) else { + return NextPage::Done; + }; + let Some(href) = next.href.as_deref() else { + return NextPage::Unparseable; + }; + let query = href.split_once('?').map_or("", |(_, query)| query); + let mut token = None; + // `Ok`/absent (no `pageTokenDirection` at all — it's documented optional) + // vs `Err` (present but not one of the enum's values): only the latter + // is unparseable. Conflating "absent" with "invalid" would make an + // ordinary next link with no direction fail alongside a genuinely + // malformed one. + let mut direction = Ok(None); + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + match key.as_ref() { + "pageToken" => token = Some(value.into_owned()), + "pageTokenDirection" => { + direction = types::ListDomainsPageTokenDirection::try_from(value.as_ref()) + .map(Some) + .map_err(|_| ()); + } + _ => {} + } + } + match (token, direction) { + (Some(token), Ok(direction)) => NextPage::Token(token, direction), + _ => NextPage::Unparseable, + } +} + +/// Fetch every domain matching `statuses`/`visible_only`, following v3's +/// `links[rel=next]` cursor until the API reports no further page — or until +/// `stop_at` items have been accumulated, when an explicit `--limit`/ +/// `--offset` window needs no more than that many (cli-engine's own +/// pagination pipeline slices the exact window from whatever this returns; +/// fetching further would be wasted work against a tightly rate-limited +/// API). `stop_at: None` fetches everything, matching this command's +/// pre-v3 behavior of returning every domain when unflagged. +async fn fetch_domains( + client: &domains_client::Client, + statuses: &[String], + visible_only: bool, + stop_at: Option, + debug: bool, +) -> Result> { + let page_size = std::num::NonZeroU64::new(MAX_PAGE_SIZE).expect("nonzero constant"); + let mut items = Vec::new(); + let mut page_token = None; + for _ in 0..MAX_PAGES { + let mut req = client.list_domains().page_size(page_size); + if !statuses.is_empty() { + // `statuses` is `style: form, explode: false` — one + // comma-joined value, not repeated `statuses=` pairs + // (progenitor always seq-serializes a `Vec` as repeated pairs + // regardless of the spec's `explode` setting; see + // `comma_joined`'s doc comment / DEVEX-882). + req = req.statuses(comma_joined(statuses.to_vec())); + } else if visible_only { + req = req.lifecycle_groups( + comma_joined( + DEFAULT_VISIBLE_GROUPS + .into_iter() + .map(str::to_string) + .collect(), + ) + .into_iter() + .map(types::DomainLifecycleGroup::from) + .collect::>(), + ); + } + if let Some((token, direction)) = page_token.take() { + req = req.page_token(token); + if let Some(direction) = direction { + req = req.page_token_direction(direction); + } + } + let collection = match req.send().await { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("listing domains", debug, e).await), + }; + items.extend(collection.items.unwrap_or_default()); + if stop_at.is_some_and(|n| items.len() >= n) { + return Ok(items); + } + let next = collection + .links + .map_or(NextPage::Done, |links| next_page_token(&links)); + page_token = match next { + NextPage::Done => return Ok(items), + NextPage::Token(token, direction) => Some((token, direction)), + NextPage::Unparseable => { + return Err(CliCoreError::message(format!( + "domain list: the API reported another page of results but this CLI \ + couldn't parse its pagination link ({} domains fetched before stopping); \ + this looks like an API or CLI bug, not a real account size", + items.len() + ))); + } + }; + } + // Every earlier iteration returned as soon as a page had no `Token` + // (either genuinely `Done`, or an error on `Unparseable`), so reaching + // here means MAX_PAGES was exhausted with a next page still pending — + // a looping `next` link, not a real account size. Error instead of + // returning a partial list that would look complete to the caller. + Err(CliCoreError::message(format!( + "domain list: exceeded {MAX_PAGES} pages ({} domains fetched) without reaching the end \ + of the list; this looks like a pagination bug rather than a real account size", + items.len() + ))) +} + pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("list", "List the domains in your account") .with_long( "List the domains registered to your account. Shows domain, status, \ expiry, and auto-renew by default; use --fields to pick columns. Hides \ - domains that are cancelled or otherwise not visible unless --show-hidden \ - is passed; use --status to filter to specific status values (repeatable).", + domains that are in a terminal status unless --show-hidden \ + is passed or specific status values are specified with --status \ + (repeatable).", ) .with_system("domain") .with_tier(Tier::Read) - .with_default_fields("domain,status,expires,renewAuto") - .with_json_schema::() + .with_default_fields("domain,status,expiresAt,autoRenew") + .with_json_schema::() .with_scopes(&[DOMAINS_READ]) .with_pagination(PaginationConfig { max_limit: 500, @@ -64,19 +227,23 @@ pub(super) fn command() -> RuntimeCommandSpec { let debug = !ctx.middleware.debug.is_empty(); let statuses = parse_statuses(&args.status)?; let show_hidden = args.show_hidden; + let visible_only = wants_visible_only(&statuses, show_hidden); let client = make_client(&ctx).await?; - let mut req = client.list(); - if !statuses.is_empty() { - req = req.statuses(statuses); - } else if wants_visible_only(&statuses, show_hidden) { - req = req.status_groups(vec![types::ListStatusGroupsItem::Visible]); - } - let resp = match req.send().await { - Ok(r) => r, - Err(e) => return Err(api_error("listing domains", debug, e).await), - }; - let domains: Vec = resp - .into_inner() + // An explicit `--limit`/`--offset` window needs no more than + // `offset + limit` domains; cli-engine's pagination pipeline + // (`ctx.middleware.limit`/`.offset`, populated from those flags) + // slices the exact window from whatever this returns, so + // fetching further would be wasted requests against a tightly + // rate-limited API. Unflagged (`limit == 0`, the "unlimited" + // sentinel) fetches every domain, matching this command's + // pre-v3 behavior. + let limit = ctx.middleware.limit; + let stop_at = (limit > 0).then(|| { + usize::try_from(ctx.middleware.offset.max(0).saturating_add(limit)) + .unwrap_or(usize::MAX) + }); + let items = fetch_domains(&client, &statuses, visible_only, stop_at, debug).await?; + let domains: Vec = items .iter() .map(serde_json::to_value) .collect::>() @@ -93,7 +260,9 @@ pub(super) fn command() -> RuntimeCommandSpec { #[cfg(test)] mod tests { - use super::{command, parse_statuses, wants_visible_only}; + use super::{ + NextPage, command, fetch_domains, next_page_token, parse_statuses, wants_visible_only, + }; use cli_engine::PaginationConfig; use domains_client::types; @@ -114,13 +283,9 @@ mod tests { #[test] fn parse_statuses_is_case_insensitive_and_validates() { - use types::ListStatusesItem; let parsed = parse_statuses(&["active".to_string(), "CANCELLED".to_string()]) .expect("valid statuses"); - assert_eq!( - parsed, - vec![ListStatusesItem::Active, ListStatusesItem::Cancelled] - ); + assert_eq!(parsed, vec!["ACTIVE".to_string(), "CANCELLED".to_string()]); assert!(parse_statuses(&[]).expect("empty ok").is_empty()); let err = parse_statuses(&["bogus".to_string()]).expect_err("should reject"); assert!(err.to_string().contains("invalid --status"), "{err}"); @@ -130,9 +295,291 @@ mod tests { fn wants_visible_only_defaults_true_but_yields_to_status_or_show_hidden() { assert!(wants_visible_only(&[], false)); assert!(!wants_visible_only(&[], true)); - assert!(!wants_visible_only( - &[types::ListStatusesItem::Cancelled], - false - )); + assert!(!wants_visible_only(&["CANCELLED".to_string()], false)); + } + + #[tokio::test] + async fn statuses_are_sent_as_a_single_comma_joined_query_param() { + // Regression for DEVEX-882 (mirrors `agreements.rs`'s equivalent + // test): v3's `statuses` query param is `style: form, explode: + // false` — one comma-joined value. progenitor's generated + // `statuses()` setter always seq-serializes a `Vec` as repeated + // `statuses=` pairs, which the live API rejects with + // `MISMATCH_FORMAT` (confirmed against a real test-environment + // server) — multiple `--status` values must be joined first. + let server = httpmock::MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path("/v3/domains/domain-names") + .query_param("statuses", "ACTIVE,EXPIRED"); + then.status(200) + .json_body(serde_json::json!({ "items": [] })); + }) + .await; + let client = + domains_client::client_with_auth(&server.base_url(), "Bearer tok", "test", "req-1") + .expect("build client"); + + let statuses = + super::super::common::comma_joined(vec!["ACTIVE".to_string(), "EXPIRED".to_string()]); + client + .list_domains() + .statuses(statuses) + .send() + .await + .expect("request succeeds"); + + mock.assert_async().await; + } + + #[tokio::test] + async fn default_visible_only_lifecycle_groups_are_comma_joined() { + // Same DEVEX-882 class of bug applies to `lifecycleGroups` — the + // no-flags default view (hide non-visible/terminal domains). + let server = httpmock::MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path("/v3/domains/domain-names") + .query_param("lifecycleGroups", "PENDING,REGISTERED,PENDING_TERMINAL"); + then.status(200) + .json_body(serde_json::json!({ "items": [] })); + }) + .await; + let client = + domains_client::client_with_auth(&server.base_url(), "Bearer tok", "test", "req-1") + .expect("build client"); + + let groups = super::super::common::comma_joined( + super::DEFAULT_VISIBLE_GROUPS + .into_iter() + .map(str::to_string) + .collect(), + ) + .into_iter() + .map(types::DomainLifecycleGroup::from) + .collect::>(); + client + .list_domains() + .lifecycle_groups(groups) + .send() + .await + .expect("request succeeds"); + + mock.assert_async().await; + } + + fn next_link(href: &str) -> Vec { + vec![types::LinkDescription { + href: Some(href.to_string()), + rel: Some("next".to_string()), + ..Default::default() + }] + } + + #[test] + fn next_page_token_parses_token_and_direction_from_the_next_link() { + let links = next_link( + "https://api.example.com/v3/domains/domain-names?pageToken=abc123&pageTokenDirection=forward", + ); + assert_eq!( + next_page_token(&links), + NextPage::Token( + "abc123".to_string(), + Some(types::ListDomainsPageTokenDirection::Forward) + ) + ); + } + + #[test] + fn next_page_token_parses_a_relative_href() { + // Regression: the v3 spec's own link examples use relative paths + // (e.g. `/v3/domains/domain-names?...`), which `url::Url::parse` + // rejects outright since a relative reference isn't a standalone + // URL — that would have silently stopped pagination after page one + // against a real API response shaped this way. + let links = next_link("/v3/domains/domain-names?pageToken=abc123"); + assert_eq!( + next_page_token(&links), + NextPage::Token("abc123".to_string(), None) + ); + } + + #[test] + fn next_page_token_is_done_without_a_next_rel() { + let mut links = next_link("https://api.example.com/v3/domains/domain-names?pageToken=abc"); + links[0].rel = Some("self".to_string()); + assert_eq!(next_page_token(&links), NextPage::Done); + } + + #[test] + fn next_page_token_is_unparseable_when_the_next_link_has_no_page_token() { + // Per the spec, `rel=next` only ever appears when more data exists — + // so a present-but-unparseable link must NOT be conflated with + // "genuinely done" (that would silently truncate a real account's + // domain list). + let links = next_link("https://api.example.com/v3/domains/domain-names"); + assert_eq!(next_page_token(&links), NextPage::Unparseable); + } + + #[test] + fn next_page_token_is_unparseable_when_direction_is_present_but_invalid() { + // A `pageTokenDirection` outside the enum's two values is a + // malformed link, not "no direction given" — must not silently + // proceed with `direction: None` and risk the wrong next-page + // request. + let links = next_link( + "https://api.example.com/v3/domains/domain-names?pageToken=abc&pageTokenDirection=sideways", + ); + assert_eq!(next_page_token(&links), NextPage::Unparseable); + } + + #[tokio::test] + async fn fetch_domains_follows_the_next_link_across_pages() { + // Regression: `listDomains` is cursor-paginated (`DomainCollection` + // with `links[rel=next]`), but a single `send()` only returns the + // first page — an account with more domains than one page would get + // silently truncated results. This proves `fetch_domains` follows + // `next` until it's exhausted rather than stopping after page one. + let server = httpmock::MockServer::start_async().await; + let page1 = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path("/v3/domains/domain-names") + .query_param_missing("pageToken"); + then.status(200).json_body(serde_json::json!({ + "items": [{"domain": "a.com"}], + "links": [{ + "rel": "next", + "href": "/v3/domains/domain-names?pageToken=page-2", + }], + })); + }) + .await; + let page2 = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path("/v3/domains/domain-names") + .query_param("pageToken", "page-2"); + then.status(200).json_body(serde_json::json!({ + "items": [{"domain": "b.com"}], + })); + }) + .await; + let client = + domains_client::client_with_auth(&server.base_url(), "Bearer tok", "test", "req-1") + .expect("build client"); + + let items = fetch_domains(&client, &[], false, None, false) + .await + .expect("fetch succeeds"); + + assert_eq!( + items + .iter() + .filter_map(|d| d.domain.clone()) + .collect::>(), + vec!["a.com".to_string(), "b.com".to_string()] + ); + page1.assert_async().await; + page2.assert_async().await; + } + + #[tokio::test] + async fn fetch_domains_stops_once_stop_at_is_satisfied_without_fetching_the_next_page() { + // The `--limit`/`--offset` window is satisfied by page one alone, so + // `fetch_domains` must not spend a second request (and a second hit + // against this API's tight rate limit) fetching page two. + let server = httpmock::MockServer::start_async().await; + let page1 = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path("/v3/domains/domain-names"); + then.status(200).json_body(serde_json::json!({ + "items": [{"domain": "a.com"}, {"domain": "b.com"}], + "links": [{ + "rel": "next", + "href": "/v3/domains/domain-names?pageToken=page-2", + }], + })); + }) + .await; + let client = + domains_client::client_with_auth(&server.base_url(), "Bearer tok", "test", "req-1") + .expect("build client"); + + let items = fetch_domains(&client, &[], false, Some(2), false) + .await + .expect("fetch succeeds"); + + assert_eq!(items.len(), 2); + assert_eq!( + page1.calls_async().await, + 1, + "must not fetch beyond the satisfied stop_at window" + ); + } + + #[tokio::test] + async fn fetch_domains_errors_instead_of_silently_truncating_a_looping_next_link() { + // A malformed or looping `next` link must fail loudly rather than + // return a partial list that looks complete to the caller. + let server = httpmock::MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path("/v3/domains/domain-names"); + then.status(200).json_body(serde_json::json!({ + "items": [{"domain": "a.com"}], + "links": [{ + "rel": "next", + "href": "/v3/domains/domain-names?pageToken=always-more", + }], + })); + }) + .await; + let client = + domains_client::client_with_auth(&server.base_url(), "Bearer tok", "test", "req-1") + .expect("build client"); + + let err = fetch_domains(&client, &[], false, None, false) + .await + .expect_err("must not silently return a partial list"); + assert!(err.to_string().contains("pages"), "{err}"); + assert_eq!(mock.calls_async().await, super::MAX_PAGES); + } + + #[tokio::test] + async fn fetch_domains_errors_immediately_on_an_unparseable_next_link() { + // Per the spec, `rel=next` only ever appears when more data exists, + // so a `next` link present but missing a `pageToken` must error on + // the spot — not be treated as "done" and silently return a partial + // list that looks complete. This must fire on page one, well before + // any MAX_PAGES cap. + let server = httpmock::MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path("/v3/domains/domain-names"); + then.status(200).json_body(serde_json::json!({ + "items": [{"domain": "a.com"}], + "links": [{"rel": "next", "href": "/v3/domains/domain-names"}], + })); + }) + .await; + let client = + domains_client::client_with_auth(&server.base_url(), "Bearer tok", "test", "req-1") + .expect("build client"); + + let err = fetch_domains(&client, &[], false, None, false) + .await + .expect_err("must not silently return a partial list"); + assert!(err.to_string().contains("couldn't parse"), "{err}"); + assert_eq!( + mock.calls_async().await, + 1, + "must error on page one, not loop to MAX_PAGES" + ); } } diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index 721008ed..03b6f0f1 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -263,18 +263,38 @@ pub(super) fn command() -> RuntimeCommandSpec { None => None, }; + // Echo the quote's fees (e.g. a premium domain's one-time + // acquisition surcharge) back verbatim into `consent.acknowledgedFees` + // — the server cross-checks this against the locked quote token and + // rejects a mismatch with `422 quote_mismatch`. `None`/absent here + // means the quote carried no fees (the common, non-premium case), + // which correctly serializes as no fees to acknowledge. + let acknowledged_fees = match cached.fees.as_ref() { + Some(v) => serde_json::from_value::>(v.clone()).map_err(|e| { + CliCoreError::message(format!( + "the cached quote for {domain} is corrupt or from an older CLI \ + version (could not read its fees: {e}); re-run \ + `gddy domain quote {domain}` for a fresh quote." + )) + })?, + None => vec![], + }; + let consent = types::Consent { agreed_at: types::DateTime(iso_datetime(chrono::Utc::now())), // Server-derived from the execute request's auth context; the // caller no longer supplies this. agreed_by: None, agreement_types, + acknowledged_fees, }; let registration = types::Registration { consent, created_at: None, domain: domain.clone(), expires_at: None, + // Server-populated on the response; not sent in the request. + fees: vec![], links: vec![], operation_id: None, order_id: None, @@ -414,6 +434,7 @@ pub(super) fn command() -> RuntimeCommandSpec { mod tests { use super::{consent_principal, iso_datetime, next_action_description, purchase_consent_types}; use cli_engine::Credential; + use domains_client::types; #[test] fn purchase_consent_requires_agree_then_confirm() { @@ -546,4 +567,53 @@ mod tests { assert_eq!(s, "2026-06-30T22:34:43Z"); assert!(!s.contains('+'), "must not use a numeric offset: {s}"); } + + #[test] + fn empty_optional_vecs_are_omitted_not_sent_as_empty_arrays() { + // `Consent.acknowledgedFees` (minItems: 1 when present, "omit when + // the quote carries no purchase fees") and `Registration.fees` + // (readOnly) must never be serialized as `[]` for the common + // non-premium case — that would violate the schema and risk + // rejection. Pins the `skip_serializing_if` progenitor generates for + // these fields, since this code relies on it by constructing both + // with `vec![]` rather than omitting the field outright. + let consent = types::Consent { + agreed_at: types::DateTime("2026-06-30T00:00:00Z".to_string()), + agreed_by: None, + agreement_types: vec![types::AgreementType::ApiDpa], + acknowledged_fees: vec![], + }; + let value = serde_json::to_value(&consent).expect("serializes"); + assert!( + !value + .as_object() + .expect("object") + .contains_key("acknowledgedFees"), + "{value}" + ); + + let registration = types::Registration { + consent, + created_at: None, + domain: "example.com".to_string(), + expires_at: None, + fees: vec![], + links: vec![], + operation_id: None, + order_id: None, + period: std::num::NonZeroU64::new(1).expect("nonzero"), + price: None, + profile: None, + profile_id: None, + quote_token: Some(types::Uuid("tok-abc".to_string())), + registration_id: None, + status: None, + updated_at: None, + }; + let value = serde_json::to_value(®istration).expect("serializes"); + assert!( + !value.as_object().expect("object").contains_key("fees"), + "{value}" + ); + } } diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 9aed7824..e886d2de 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -33,8 +33,40 @@ output_schema!(DomainQuoteResult { "agreements": "string", optional; "requiredAgreements": "[]object", optional; "resolved": "object", optional; + // Present only for premium (Afternic) domains: `inventory` is `PREMIUM` and + // `fees` carries the one-time acquisition surcharge to acknowledge at purchase. + "inventory": "string", optional; + "fees": "[]object", optional; }); +/// Render a quote's `fees` array (e.g. a premium domain's one-time acquisition +/// surcharge) as `{type, amount, currency}` objects for display — mirrors how +/// `quote_to_json` renders prices via [`format_money`]. `None` (rather than an +/// empty array) when the quote carried no fees, so the field is omitted from +/// output entirely instead of showing an empty list. +fn fees_to_json(fees: &[types::Fee]) -> Option { + if fees.is_empty() { + return None; + } + Some(json!( + fees.iter() + .map(|f| { + let mut out = json!({}); + if let Some(t) = f.type_.as_ref() { + out["type"] = json!(t.to_string()); + } + if let Some(amount) = f.fee.as_ref().and_then(format_money) { + out["amount"] = json!(amount); + if let Some(code) = f.fee.as_ref().and_then(|m| m.currency_code.as_ref()) { + out["currency"] = json!(code.to_string()); + } + } + out + }) + .collect::>() + )) +} + /// Build the inline registration profile (contacts + preferences) sent with a /// quote. Always returns a profile: `auto_renew` and `privacy` are always set /// (from the flags/defaults), while `contacts` and `name_servers` are populated @@ -136,6 +168,12 @@ fn quote_to_json(quote: &types::RegistrationQuote, request_domain: &str) -> serd if let Some(irreversible) = quote.irreversible { out["irreversible"] = json!(irreversible); } + if let Some(inventory) = quote.inventory.as_ref() { + out["inventory"] = json!(inventory.to_string()); + } + if let Some(fees) = quote.fees.as_ref().and_then(|f| fees_to_json(f)) { + out["fees"] = fees; + } // The effective settings the registration would apply (contacts source, // privacy, auto-renew, nameservers) — so the user reviews what they're buying. if let Some(resolved) = quote.resolved.as_ref() @@ -219,6 +257,12 @@ fn view_columns() -> Vec { TableColumn::new("quoteToken", "Quote Token").no_truncate(true), TableColumn::new("expiresAt", "Expires At"), TableColumn::new("irreversible", "Irreversible"), + TableColumn::new("inventory", "Inventory"), + TableColumn::new("fees", "Fees").nested(vec![ + TableColumn::new("type", "Type"), + TableColumn::new("amount", "Amount"), + TableColumn::new("currency", "Currency"), + ]), TableColumn::new("agreements", "Agreements"), TableColumn::new("requiredAgreements", "Required Agreements").nested(vec![ TableColumn::new("agreementType", "Type"), @@ -312,6 +356,21 @@ pub(super) fn command() -> RuntimeCommandSpec { let token = token.to_string(); let agreements = quote.required_agreements.clone().unwrap_or_default(); let (agreement_types, agreement_titles) = agreement_types_and_titles(&agreements); + // Cache verbatim so `purchase` can echo it into + // `consent.acknowledgedFees` (required for premium domains; the + // server rejects a mismatch with `quote_mismatch`). Serializing + // this in-memory value effectively never fails, but if it did, + // silently caching `None` would surface as a confusing + // `quote_mismatch` at purchase time instead of here — fail fast, + // same as `profile_json` above. + let fees_json = match quote.fees.as_ref().filter(|f| !f.is_empty()) { + Some(fees) => Some(serde_json::to_value(fees).map_err(|e| { + CliCoreError::message(format!( + "could not serialize the quote's fees for the quote cache: {e}" + )) + })?), + None => None, + }; let cached = quote_cache::CachedQuote { domain: quote.domain.clone().unwrap_or_else(|| domain.clone()), period: quote.period.map_or(period, |p| p.get()), @@ -331,6 +390,7 @@ pub(super) fn command() -> RuntimeCommandSpec { // attempt for this token reuses it (a retry after a lost // response can't double-charge). idempotency_key: Some(uuid::Uuid::new_v4().to_string()), + fees: fees_json, }; if let Err(e) = quote_cache::save(&token, cached) { // Non-fatal: the quote is still shown, but purchase won't @@ -425,4 +485,46 @@ mod tests { assert!(rendered.contains("Jane Smith"), "{rendered}"); assert!(rendered.contains("PROFILE"), "{rendered}"); } + + /// Proves `view_columns()` renders a premium domain's `inventory`/`fees` + /// shaped like what `quote_to_json` actually emits (`fees_to_json` + /// builds each entry from a `json!` literal with these exact keys) as a + /// nested table — a mismatch would silently drop them from `--fields + /// all` output. + #[test] + fn quote_result_renders_inventory_and_fees_as_a_nested_table() { + let quote = json!({ + "domain": "premium-example.com", + "available": true, + "inventory": "PREMIUM", + "fees": [ + {"type": "ONE_TIME_PREMIUM_DOMAIN_PURCHASE", "amount": "3900.00", "currency": "USD"}, + ], + }); + let envelope = cli_engine::Envelope::success(quote, "domain"); + let rendered = cli_engine::render_human_with_view(&envelope, Some(&view_columns()), ""); + assert!(rendered.contains("Inventory:"), "{rendered}"); + assert!(rendered.contains("PREMIUM"), "{rendered}"); + assert!(rendered.contains("Fees:"), "{rendered}"); + assert!( + rendered.contains("ONE_TIME_PREMIUM_DOMAIN_PURCHASE"), + "{rendered}" + ); + assert!(rendered.contains("3900.00"), "{rendered}"); + } + + /// A non-premium quote (the common case) renders `Inventory:`/`Fees:` as + /// blank rows, same as every other optional field in this view — but + /// must never leak a literal `null` (the regression `fees_to_json`'s + /// `type` key fix targets). + #[test] + fn quote_result_never_leaks_a_literal_null_when_fees_are_absent() { + let quote = json!({ + "domain": "example.com", + "available": true, + }); + let envelope = cli_engine::Envelope::success(quote, "domain"); + let rendered = cli_engine::render_human_with_view(&envelope, Some(&view_columns()), ""); + assert!(!rendered.contains("null"), "{rendered}"); + } } diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index e40dc8aa..26a806ba 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -260,6 +260,9 @@ mod tests { price: Some(money(price, "USD")), renewal_price: Some(money(renewal, "USD")), term: Some(types::Term::Year), + fees: None, + first_term_price: None, + recommended: None, } } @@ -289,6 +292,9 @@ mod tests { price: None, renewal_price: Some(money(3799, "USD")), term: Some(types::Term::Year), + fees: None, + first_term_price: None, + recommended: None, }; let suggestion = types::Suggestion { domain: Some("example.com".to_string()), diff --git a/rust/src/quote_cache.rs b/rust/src/quote_cache.rs index b15b7a84..9ca8f522 100644 --- a/rust/src/quote_cache.rs +++ b/rust/src/quote_cache.rs @@ -64,6 +64,14 @@ pub struct CachedQuote { /// written by an older CLI, where `purchase` falls back to generating one. #[serde(default)] pub idempotency_key: Option, + /// The quote's `fees` array (e.g. a premium domain's one-time acquisition + /// charge), serialized verbatim. `purchase` must echo these back into + /// `consent.acknowledgedFees` exactly (same types/amounts/currencies) or the + /// server rejects the execute with `422 quote_mismatch`. `None` when the + /// quote carried no fees (the common, non-premium case) or for quotes cached + /// by an older CLI that predates premium-domain support. + #[serde(default)] + pub fees: Option, } /// The result of looking a token up in the cache. @@ -222,6 +230,7 @@ mod tests { expires_at: expires.map(str::to_owned), profile: None, idempotency_key: Some("idem-test".to_owned()), + fees: None, } }