diff --git a/.agents/skills/godaddy-cli/SKILL.md b/.agents/skills/godaddy-cli/SKILL.md index 49baf6d..aaa05f3 100644 --- a/.agents/skills/godaddy-cli/SKILL.md +++ b/.agents/skills/godaddy-cli/SKILL.md @@ -260,9 +260,9 @@ Release and deploy accept `--config ` and `--environment `. Use `godaddy api` for endpoint discovery and authenticated API calls: ```bash -# List domains and endpoints -godaddy api list -godaddy api list --domain commerce +# List API domains, and endpoints within a domain +godaddy api domain list +godaddy api endpoint list --domain commerce # Describe one endpoint (operation ID or path) godaddy api describe commerce.location.verify-address @@ -283,15 +283,15 @@ godaddy api call /v1/commerce/orders -s commerce.orders:read ``` Compatibility behavior: -- `godaddy api ` still works. If the token after `api` is not one of `list`, `describe`, `search`, or `call`, the CLI treats it as an endpoint and executes `api call`. +- `godaddy api ` still works. If the token after `api` is not one of `domain`, `endpoint`, `describe`, `search`, or `call`, the CLI treats it as an endpoint and executes `api call`. - This means legacy usage like `godaddy api /v1/commerce/location/addresses` remains supported. -As with other large result sets, `api list` may be truncated in the inline JSON response. When `truncated: true`, read the `full_output` file path for complete results. +As with other large result sets, `api domain list` / `api endpoint list` may be truncated in the inline JSON response. When `truncated: true`, read the `full_output` file path for complete results. Address task rule: - For requests like "find/search/verify an address", start with: `godaddy api search address` - `godaddy api list --domain location` + `godaddy api endpoint list --domain location` `godaddy api describe /location/addresses` - Do not use `godaddy actions describe` for generic API endpoint discovery. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 880006f..76f0159 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -542,9 +542,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-engine" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac2356eabf48d9c8142a70e0da391f065986911dfcc8c49381fa4559795b98f" +checksum = "5d52e2b73598b7c6dbbaf3946a476a888d0cda6a36d2d98c1f477f8d7a8034c1" dependencies = [ "async-trait", "base64 0.22.1", @@ -761,6 +761,7 @@ dependencies = [ "progenitor", "progenitor-client", "reqwest", + "schemars 1.2.1", "serde", "serde_json", "syn 2.0.117", @@ -1362,7 +1363,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -2169,7 +2170,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -2206,7 +2207,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4f240e8..4111bef 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" async-trait = "0.1" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } clap = { version = "4.5", features = ["std", "string"] } -cli-engine = { features = ["pkce-auth"], version = "0.2.2" } +cli-engine = { features = ["pkce-auth"], version = "0.3.0" } dirs = "6" domains-client = { path = "domains-client" } fancy-regex = "0.14" diff --git a/rust/domains-client/Cargo.toml b/rust/domains-client/Cargo.toml index 3292389..16e88ca 100644 --- a/rust/domains-client/Cargo.toml +++ b/rust/domains-client/Cargo.toml @@ -3,7 +3,7 @@ name = "domains-client" version = "0.1.0" edition = "2024" license = "Proprietary" -description = "Generated GoDaddy Domains API client (availability + suggest), produced from the vendored OpenAPI 3.0 spec by progenitor at build time." +description = "Generated GoDaddy Domains API client (domains list + availability + suggest + DNS records), produced from the vendored OpenAPI 3.0 spec by progenitor at build time." [dependencies] # Pinned to 0.11 (the latest progenitor on reqwest 0.12): keeps the whole @@ -12,6 +12,10 @@ description = "Generated GoDaddy Domains API client (availability + suggest), pr # the Windows-msvc cross-build clean. progenitor-client = "0.11" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +# Pinned to match cli-engine's schemars so the generated types' derived +# `JsonSchema` impls satisfy `CommandSpec::with_json_schema::()` (a different +# schemars major would be a distinct, incompatible trait). +schemars = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" futures = "0.3" diff --git a/rust/domains-client/build.rs b/rust/domains-client/build.rs index 2873365..5c02050 100644 --- a/rust/domains-client/build.rs +++ b/rust/domains-client/build.rs @@ -1,8 +1,9 @@ //! Generates the typed Domains API client from the vendored OpenAPI 3.0 spec. //! //! The spec (`openapi/domains.oas3.json`) is committed and trimmed to the -//! availability + suggest operations (see `scripts/regenerate-spec.sh`); this -//! build step is hermetic and never touches the network. +//! domains-list + availability + suggest + DNS-record operations (see +//! `scripts/regenerate-spec.sh`); this build step is hermetic and never touches +//! the network. use std::{env, fs, path::Path}; @@ -20,6 +21,10 @@ fn main() -> Result<(), Box> { // are simply omitted rather than passed as `None`. let mut settings = progenitor::GenerationSettings::new(); settings.with_interface(progenitor::InterfaceStyle::Builder); + // Derive `schemars::JsonSchema` on the generated response/request types so + // the CLI can register them via `CommandSpec::with_json_schema::()` and + // surface their fields through `--schema`. + settings.with_derive("schemars::JsonSchema"); let mut generator = progenitor::Generator::new(&settings); let tokens = generator.generate_tokens(&spec)?; let ast = syn::parse2(tokens)?; diff --git a/rust/domains-client/openapi/domains.oas3.json b/rust/domains-client/openapi/domains.oas3.json index 4835b16..2d0e799 100644 --- a/rust/domains-client/openapi/domains.oas3.json +++ b/rust/domains-client/openapi/domains.oas3.json @@ -1,10 +1,357 @@ { "openapi": "3.0.0", "info": { - "title": "GoDaddy Domains API (availability subset)", + "title": "GoDaddy Domains API (domains list + availability + DNS records subset)", "version": "1.0.0" }, "paths": { + "/v1/domains": { + "get": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID whose domains are to be retrieved", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Only include results with `status` value in the specified set", + "in": "query", + "name": "statuses", + "required": false, + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "enum": [ + "ACTIVE", + "AWAITING_CLAIM_ACK", + "AWAITING_DOCUMENT_AFTER_TRANSFER", + "AWAITING_DOCUMENT_AFTER_UPDATE_ACCOUNT", + "AWAITING_DOCUMENT_UPLOAD", + "AWAITING_FAILED_TRANSFER_WHOIS_PRIVACY", + "AWAITING_PAYMENT", + "AWAITING_RENEWAL_TRANSFER_IN_COMPLETE", + "AWAITING_TRANSFER_IN_ACK", + "AWAITING_TRANSFER_IN_AUTH", + "AWAITING_TRANSFER_IN_AUTO", + "AWAITING_TRANSFER_IN_WHOIS", + "AWAITING_TRANSFER_IN_WHOIS_FIX", + "AWAITING_VERIFICATION_ICANN", + "AWAITING_VERIFICATION_ICANN_MANUAL", + "CANCELLED", + "CANCELLED_HELD", + "CANCELLED_REDEEMABLE", + "CANCELLED_TRANSFER", + "CONFISCATED", + "DISABLED_SPECIAL", + "EXCLUDED_INVALID_CLAIM_FIREHOSE", + "EXPIRED_REASSIGNED", + "FAILED_BACKORDER_CAPTURE", + "FAILED_DROP_IMMEDIATE_THEN_ADD", + "FAILED_PRE_REGISTRATION", + "FAILED_REDEMPTION", + "FAILED_REDEMPTION_REPORT", + "FAILED_REGISTRATION", + "FAILED_REGISTRATION_FIREHOSE", + "FAILED_RESTORATION_REDEMPTION_MOCK", + "FAILED_SETUP", + "FAILED_TRANSFER_IN", + "FAILED_TRANSFER_IN_BAD_STATUS", + "FAILED_TRANSFER_IN_REGISTRY", + "HELD_COURT_ORDERED", + "HELD_DISPUTED", + "HELD_EXPIRATION_PROTECTION", + "HELD_EXPIRED_REDEMPTION_MOCK", + "HELD_REGISTRAR_ADD", + "HELD_REGISTRAR_REMOVE", + "HELD_SHOPPER", + "HELD_TEMPORARY", + "LOCKED_ABUSE", + "LOCKED_COPYRIGHT", + "LOCKED_REGISTRY", + "LOCKED_SUPER", + "PARKED_AND_HELD", + "PARKED_EXPIRED", + "PARKED_VERIFICATION_ICANN", + "PENDING_ABORT_CANCEL_SETUP", + "PENDING_AGREEMENT_PRE_REGISTRATION", + "PENDING_APPLY_RENEWAL_CREDITS", + "PENDING_BACKORDER_CAPTURE", + "PENDING_BLOCKED_REGISTRY", + "PENDING_CANCEL_REGISTRANT_PROFILE", + "PENDING_COMPLETE_REDEMPTION_WITHOUT_RECEIPT", + "PENDING_COMPLETE_REGISTRANT_PROFILE", + "PENDING_COO", + "PENDING_COO_COMPLETE", + "PENDING_DNS", + "PENDING_DNS_ACTIVE", + "PENDING_DNS_INACTIVE", + "PENDING_DOCUMENT_VALIDATION", + "PENDING_DOCUMENT_VERIFICATION", + "PENDING_DROP_IMMEDIATE", + "PENDING_DROP_IMMEDIATE_THEN_ADD", + "PENDING_EPP_CREATE", + "PENDING_EPP_DELETE", + "PENDING_EPP_UPDATE", + "PENDING_ESCALATION_REGISTRY", + "PENDING_EXPIRATION", + "PENDING_EXPIRATION_RESPONSE", + "PENDING_EXPIRATION_SYNC", + "PENDING_EXPIRED_REASSIGNMENT", + "PENDING_EXPIRE_AUTO_ADD", + "PENDING_EXTEND_REGISTRANT_PROFILE", + "PENDING_FAILED_COO", + "PENDING_FAILED_EPP_CREATE", + "PENDING_FAILED_HELD", + "PENDING_FAILED_PURCHASE_PREMIUM", + "PENDING_FAILED_RECONCILE_FIREHOSE", + "PENDING_FAILED_REDEMPTION_WITHOUT_RECEIPT", + "PENDING_FAILED_RELEASE_PREMIUM", + "PENDING_FAILED_RENEW_EXPIRATION_PROTECTION", + "PENDING_FAILED_RESERVE_PREMIUM", + "PENDING_FAILED_SUBMIT_FIREHOSE", + "PENDING_FAILED_TRANSFER_ACK_PREMIUM", + "PENDING_FAILED_TRANSFER_IN_ACK_PREMIUM", + "PENDING_FAILED_TRANSFER_IN_PREMIUM", + "PENDING_FAILED_TRANSFER_PREMIUM", + "PENDING_FAILED_TRANSFER_SUBMIT_PREMIUM", + "PENDING_FAILED_UNLOCK_PREMIUM", + "PENDING_FAILED_UPDATE_API", + "PENDING_FRAUD_VERIFICATION", + "PENDING_FRAUD_VERIFIED", + "PENDING_GET_CONTACTS", + "PENDING_GET_HOSTS", + "PENDING_GET_NAME_SERVERS", + "PENDING_GET_STATUS", + "PENDING_HOLD_ESCROW", + "PENDING_HOLD_REDEMPTION", + "PENDING_LOCK_CLIENT_REMOVE", + "PENDING_LOCK_DATA_QUALITY", + "PENDING_LOCK_THEN_HOLD_REDEMPTION", + "PENDING_PARKING_DETERMINATION", + "PENDING_PARK_INVALID_WHOIS", + "PENDING_PARK_INVALID_WHOIS_REMOVAL", + "PENDING_PURCHASE_PREMIUM", + "PENDING_RECONCILE", + "PENDING_RECONCILE_FIREHOSE", + "PENDING_REDEMPTION", + "PENDING_REDEMPTION_REPORT", + "PENDING_REDEMPTION_REPORT_COMPLETE", + "PENDING_REDEMPTION_REPORT_SUBMITTED", + "PENDING_REDEMPTION_WITHOUT_RECEIPT", + "PENDING_REDEMPTION_WITHOUT_RECEIPT_MOCK", + "PENDING_RELEASE_PREMIUM", + "PENDING_REMOVAL", + "PENDING_REMOVAL_HELD", + "PENDING_REMOVAL_PARKED", + "PENDING_REMOVAL_UNPARK", + "PENDING_RENEWAL", + "PENDING_RENEW_EXPIRATION_PROTECTION", + "PENDING_RENEW_INFINITE", + "PENDING_RENEW_LOCKED", + "PENDING_RENEW_WITHOUT_RECEIPT", + "PENDING_REPORT_REDEMPTION_WITHOUT_RECEIPT", + "PENDING_RESERVE_PREMIUM", + "PENDING_RESET_VERIFICATION_ICANN", + "PENDING_RESPONSE_FIREHOSE", + "PENDING_RESTORATION", + "PENDING_RESTORATION_INACTIVE", + "PENDING_RESTORATION_REDEMPTION_MOCK", + "PENDING_RETRY_EPP_CREATE", + "PENDING_RETRY_HELD", + "PENDING_SEND_AUTH_CODE", + "PENDING_SETUP", + "PENDING_SETUP_ABANDON", + "PENDING_SETUP_AGREEMENT_LANDRUSH", + "PENDING_SETUP_AGREEMENT_SUNRISE2_A", + "PENDING_SETUP_AGREEMENT_SUNRISE2_B", + "PENDING_SETUP_AGREEMENT_SUNRISE2_C", + "PENDING_SETUP_AUTH", + "PENDING_SETUP_DNS", + "PENDING_SETUP_FAILED", + "PENDING_SETUP_REVIEW", + "PENDING_SETUP_SUNRISE", + "PENDING_SETUP_SUNRISE_PRE", + "PENDING_SETUP_SUNRISE_RESPONSE", + "PENDING_SUBMIT_FAILURE", + "PENDING_SUBMIT_FIREHOSE", + "PENDING_SUBMIT_HOLD_FIREHOSE", + "PENDING_SUBMIT_HOLD_LANDRUSH", + "PENDING_SUBMIT_HOLD_SUNRISE", + "PENDING_SUBMIT_LANDRUSH", + "PENDING_SUBMIT_RESPONSE_FIREHOSE", + "PENDING_SUBMIT_RESPONSE_LANDRUSH", + "PENDING_SUBMIT_RESPONSE_SUNRISE", + "PENDING_SUBMIT_SUCCESS_FIREHOSE", + "PENDING_SUBMIT_SUCCESS_LANDRUSH", + "PENDING_SUBMIT_SUCCESS_SUNRISE", + "PENDING_SUBMIT_SUNRISE", + "PENDING_SUBMIT_WAITING_LANDRUSH", + "PENDING_SUCCESS_PRE_REGISTRATION", + "PENDING_SUSPENDED_DATA_QUALITY", + "PENDING_TRANSFER_ACK_PREMIUM", + "PENDING_TRANSFER_IN", + "PENDING_TRANSFER_IN_ACK", + "PENDING_TRANSFER_IN_ACK_PREMIUM", + "PENDING_TRANSFER_IN_BAD_REGISTRANT", + "PENDING_TRANSFER_IN_CANCEL", + "PENDING_TRANSFER_IN_CANCEL_REGISTRY", + "PENDING_TRANSFER_IN_COMPLETE_ACK", + "PENDING_TRANSFER_IN_DELETE", + "PENDING_TRANSFER_IN_LOCK", + "PENDING_TRANSFER_IN_NACK", + "PENDING_TRANSFER_IN_NOTIFICATION", + "PENDING_TRANSFER_IN_PREMIUM", + "PENDING_TRANSFER_IN_RELEASE", + "PENDING_TRANSFER_IN_RESPONSE", + "PENDING_TRANSFER_IN_UNDERAGE", + "PENDING_TRANSFER_OUT", + "PENDING_TRANSFER_OUT_ACK", + "PENDING_TRANSFER_OUT_NACK", + "PENDING_TRANSFER_OUT_PREMIUM", + "PENDING_TRANSFER_OUT_UNDERAGE", + "PENDING_TRANSFER_OUT_VALIDATION", + "PENDING_TRANSFER_PREMIUM", + "PENDING_TRANSFER_PREMUIM", + "PENDING_TRANSFER_SUBMIT_PREMIUM", + "PENDING_UNLOCK_DATA_QUALITY", + "PENDING_UNLOCK_PREMIUM", + "PENDING_UPDATE", + "PENDING_UPDATED_REGISTRANT_DATA_QUALITY", + "PENDING_UPDATE_ACCOUNT", + "PENDING_UPDATE_API", + "PENDING_UPDATE_API_RESPONSE", + "PENDING_UPDATE_AUTH", + "PENDING_UPDATE_CONTACTS", + "PENDING_UPDATE_CONTACTS_PRIVACY", + "PENDING_UPDATE_DNS", + "PENDING_UPDATE_DNS_SECURITY", + "PENDING_UPDATE_ELIGIBILITY", + "PENDING_UPDATE_EPP_CONTACTS", + "PENDING_UPDATE_MEMBERSHIP", + "PENDING_UPDATE_OWNERSHIP", + "PENDING_UPDATE_OWNERSHIP_AUTH_AUCTION", + "PENDING_UPDATE_OWNERSHIP_HELD", + "PENDING_UPDATE_REGISTRANT", + "PENDING_UPDATE_REPO", + "PENDING_VALIDATION_DATA_QUALITY", + "PENDING_VERIFICATION_FRAUD", + "PENDING_VERIFICATION_STATUS", + "PENDING_VERIFY_REGISTRANT_DATA_QUALITY", + "RESERVED", + "RESERVED_PREMIUM", + "REVERTED", + "SUSPENDED_VERIFICATION_ICANN", + "TRANSFERRED_OUT", + "UNLOCKED_ABUSE", + "UNLOCKED_SUPER", + "UNPARKED_AND_UNHELD", + "UPDATED_OWNERSHIP", + "UPDATED_OWNERSHIP_HELD" + ], + "type": "string" + } + } + }, + { + "description": "Only include results with `status` value in any of the specified groups", + "in": "query", + "name": "statusGroups", + "required": false, + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "enum": [ + "INACTIVE", + "PRE_REGISTRATION", + "REDEMPTION", + "RENEWABLE", + "VERIFICATION_ICANN", + "VISIBLE" + ], + "type": "string" + } + } + }, + { + "description": "Maximum number of domains to return", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + { + "description": "Marker Domain to use as the offset in results", + "in": "query", + "name": "marker", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional details to be included in the response", + "in": "query", + "name": "includes", + "required": false, + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "enum": [ + "authCode", + "contacts", + "nameServers" + ], + "type": "string" + } + } + }, + { + "description": "Only include results that have been modified since the specified date", + "in": "query", + "name": "modifiedDate", + "required": false, + "schema": { + "type": "string", + "format": "iso-datetime" + } + } + ], + "responses": { + "200": { + "description": "Request was successful", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DomainSummary" + }, + "type": "array" + } + } + } + } + }, + "operationId": "list", + "summary": "Retrieve a list of Domains for the specified Shopper" + } + }, "/v1/domains/available": { "get": { "tags": [ @@ -444,67 +791,1108 @@ "operationId": "suggest", "summary": "Suggest alternate Domain names based on a seed Domain, a set of keywords, or the shopper's purchase history" } - } - }, - "servers": [ - { - "url": "https://api.ote-godaddy.com" - } - ], - "components": { - "schemas": { - "DomainAvailableResponse": { - "properties": { - "available": { - "description": "Whether or not the domain name is available", - "type": "boolean" - }, - "currency": { - "default": "USD", - "description": "Currency in which the `price` is listed. Only returned if tld is offered", - "format": "iso-currency-code", - "type": "string" - }, - "definitive": { - "description": "Whether or not the `available` answer has been definitively verified with the registry", - "type": "boolean" + }, + "/v1/domains/{domain}/records": { + "patch": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } }, - "domain": { - "description": "Domain name", - "type": "string" + { + "description": "Domain whose DNS Records are to be augmented", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArrayOfDNSRecord" + } + } }, - "period": { - "description": "Number of years included in the price. Only returned if tld is offered", - "format": "integer-positive", - "type": "integer" + "description": "DNS Records to add to whatever currently exists", + "required": true + }, + "responses": { + "200": { + "description": "Request was successful" + } + }, + "operationId": "recordAdd", + "summary": "Add the specified DNS Records to the specified Domain" + }, + "put": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } }, - "price": { - "description": "Price of the domain excluding taxes or fees. Only returned if tld is offered", - "format": "currency-micro-unit", - "type": "integer" + { + "description": "Domain whose DNS Records are to be replaced", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DNSRecord" + }, + "type": "array" + } + } }, - "renewalPrice": { - "description": "Price for renewing the domain excluding taxes or fees. Only returned if tld is offered", - "format": "currency-micro-unit", - "type": "integer" + "description": "DNS Records to replace whatever currently exists", + "required": true + }, + "responses": { + "200": { + "description": "Request was successful" } }, - "required": [ - "domain", - "available", - "definitive" - ] + "operationId": "recordReplace", + "summary": "Replace all DNS Records for the specified Domain" }, - "DomainSuggestion": { - "properties": { - "domain": { - "description": "Suggested domain name", - "type": "string" + "get": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be retrieved", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Request was successful", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DNSRecord" + }, + "type": "array" + } + } + } } }, - "required": [ - "domain" - ] + "operationId": "recordGetAll", + "summary": "Retrieve DNS Records for the specified Domain, optionally with the specified Type and/or Name" + } + }, + "/v1/domains/{domain}/records/{type}": { + "put": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be replaced", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be replaced", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ] + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DNSRecordCreateType" + }, + "type": "array" + } + } + }, + "description": "DNS Records to replace whatever currently exists", + "required": true + }, + "responses": { + "200": { + "description": "Request was successful" + } + }, + "operationId": "recordReplaceType", + "summary": "Replace all DNS Records for the specified Domain with the specified Type" + }, + "get": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be retrieved", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be retrieved", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ] + } + } + ], + "responses": { + "200": { + "description": "Request was successful", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DNSRecord" + }, + "type": "array" + } + } + } + } + }, + "operationId": "recordGetByType", + "summary": "Retrieve DNS Records for the specified Domain, optionally with the specified Type and/or Name" + } + }, + "/v1/domains/{domain}/records/{type}/{name}": { + "get": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be retrieved", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be retrieved", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ] + } + }, + { + "description": "DNS Record Name for which DNS Records are to be retrieved", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Number of results to skip for pagination", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Maximum number of items to return", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Request was successful", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DNSRecord" + }, + "type": "array" + } + } + } + } + }, + "operationId": "recordGet", + "summary": "Retrieve DNS Records for the specified Domain, optionally with the specified Type and/or Name" + }, + "put": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be replaced", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be replaced", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ] + } + }, + { + "description": "DNS Record Name for which DNS Records are to be replaced", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DNSRecordCreateTypeName" + }, + "type": "array" + } + } + }, + "description": "DNS Records to replace whatever currently exists", + "required": true + }, + "responses": { + "200": { + "description": "Request was successful" + } + }, + "operationId": "recordReplaceTypeName", + "summary": "Replace all DNS Records for the specified Domain with the specified Type and Name" + }, + "delete": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be deleted", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be deleted", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "SRV", + "TXT" + ] + } + }, + { + "description": "DNS Record Name for which DNS Records are to be deleted", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Request was successful" + } + }, + "operationId": "recordDeleteTypeName", + "summary": "Delete all DNS Records for the specified Domain with the specified Type and Name" + } + } + }, + "servers": [ + { + "url": "https://api.ote-godaddy.com" + } + ], + "components": { + "schemas": { + "Address": { + "properties": { + "address1": { + "format": "street-address", + "type": "string" + }, + "address2": { + "format": "street-address2", + "type": "string" + }, + "city": { + "format": "city-name", + "type": "string" + }, + "country": { + "default": "US", + "description": "Two-letter ISO country code to be used as a hint for target region

\nNOTE: These are sample values, there are many\nmore", + "enum": [ + "AC", + "AD", + "AE", + "AF", + "AG", + "AI", + "AL", + "AM", + "AO", + "AQ", + "AR", + "AS", + "AT", + "AU", + "AW", + "AX", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BM", + "BN", + "BO", + "BQ", + "BR", + "BS", + "BT", + "BV", + "BW", + "BY", + "BZ", + "CA", + "CC", + "CD", + "CF", + "CG", + "CH", + "CI", + "CK", + "CL", + "CM", + "CN", + "CO", + "CR", + "CV", + "CW", + "CX", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "EH", + "ER", + "ES", + "ET", + "FI", + "FJ", + "FK", + "FM", + "FO", + "FR", + "GA", + "GB", + "GD", + "GE", + "GF", + "GG", + "GH", + "GI", + "GL", + "GM", + "GN", + "GP", + "GQ", + "GR", + "GS", + "GT", + "GU", + "GW", + "GY", + "HK", + "HM", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IM", + "IN", + "IO", + "IQ", + "IS", + "IT", + "JE", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KR", + "KV", + "KW", + "KY", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MG", + "MH", + "MK", + "ML", + "MM", + "MN", + "MO", + "MP", + "MQ", + "MR", + "MS", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NC", + "NE", + "NF", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NU", + "NZ", + "OM", + "PA", + "PE", + "PF", + "PG", + "PH", + "PK", + "PL", + "PM", + "PN", + "PR", + "PS", + "PT", + "PW", + "PY", + "QA", + "RE", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SE", + "SG", + "SH", + "SI", + "SJ", + "SK", + "SL", + "SM", + "SN", + "SO", + "SR", + "ST", + "SV", + "SX", + "SZ", + "TC", + "TD", + "TF", + "TG", + "TH", + "TJ", + "TK", + "TL", + "TM", + "TN", + "TO", + "TP", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "UM", + "US", + "UY", + "UZ", + "VA", + "VC", + "VE", + "VG", + "VI", + "VN", + "VU", + "WF", + "WS", + "YE", + "YT", + "ZA", + "ZM", + "ZW" + ], + "format": "iso-country-code", + "type": "string" + }, + "postalCode": { + "description": "Postal or zip code", + "format": "postal-code", + "type": "string" + }, + "state": { + "description": "State or province or territory", + "format": "state-province-territory", + "type": "string" + } + } + }, + "ArrayOfDNSRecord": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DNSRecord" + } + }, + "Contact": { + "properties": { + "addressMailing": { + "$ref": "#/components/schemas/Address" + }, + "email": { + "format": "email", + "type": "string" + }, + "fax": { + "format": "phone", + "type": "string" + }, + "jobTitle": { + "type": "string" + }, + "nameFirst": { + "format": "person-name", + "type": "string" + }, + "nameLast": { + "format": "person-name", + "type": "string" + }, + "nameMiddle": { + "type": "string" + }, + "organization": { + "format": "organization-name", + "type": "string" + }, + "phone": { + "format": "phone", + "type": "string" + } + } + }, + "DNSRecord": { + "properties": { + "data": { + "type": "string" + }, + "name": { + "format": "domain", + "type": "string" + }, + "port": { + "description": "Service port (SRV only)", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "priority": { + "description": "Record priority (MX and SRV only)", + "format": "integer-positive", + "type": "integer" + }, + "protocol": { + "description": "Service protocol (SRV only)", + "type": "string" + }, + "service": { + "description": "Service type (SRV only)", + "type": "string" + }, + "ttl": { + "format": "integer-positive", + "type": "integer" + }, + "type": { + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ], + "type": "string" + }, + "weight": { + "description": "Record weight (SRV only)", + "format": "integer-positive", + "type": "integer" + } + }, + "required": [ + "type", + "name", + "data" + ] + }, + "DNSRecordCreateType": { + "properties": { + "data": { + "type": "string" + }, + "name": { + "format": "domain", + "type": "string" + }, + "port": { + "description": "Service port (SRV only)", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "priority": { + "description": "Record priority (MX and SRV only)", + "format": "integer-positive", + "type": "integer" + }, + "protocol": { + "description": "Service protocol (SRV only)", + "type": "string" + }, + "service": { + "description": "Service type (SRV only)", + "type": "string" + }, + "ttl": { + "format": "integer-positive", + "type": "integer" + }, + "weight": { + "description": "Record weight (SRV only)", + "format": "integer-positive", + "type": "integer" + } + }, + "required": [ + "name", + "data" + ] + }, + "DNSRecordCreateTypeName": { + "properties": { + "data": { + "type": "string" + }, + "port": { + "description": "Service port (SRV only)", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "priority": { + "description": "Record priority (MX and SRV only)", + "format": "integer-positive", + "type": "integer" + }, + "protocol": { + "description": "Service protocol (SRV only)", + "type": "string" + }, + "service": { + "description": "Service type (SRV only)", + "type": "string" + }, + "ttl": { + "format": "integer-positive", + "type": "integer" + }, + "weight": { + "description": "Record weight (SRV only)", + "format": "integer-positive", + "type": "integer" + } + }, + "required": [ + "data" + ] + }, + "DomainAvailableResponse": { + "properties": { + "available": { + "description": "Whether or not the domain name is available", + "type": "boolean" + }, + "currency": { + "default": "USD", + "description": "Currency in which the `price` is listed. Only returned if tld is offered", + "format": "iso-currency-code", + "type": "string" + }, + "definitive": { + "description": "Whether or not the `available` answer has been definitively verified with the registry", + "type": "boolean" + }, + "domain": { + "description": "Domain name", + "type": "string" + }, + "period": { + "description": "Number of years included in the price. Only returned if tld is offered", + "format": "integer-positive", + "type": "integer" + }, + "price": { + "description": "Price of the domain excluding taxes or fees. Only returned if tld is offered", + "format": "currency-micro-unit", + "type": "integer" + }, + "renewalPrice": { + "description": "Price for renewing the domain excluding taxes or fees. Only returned if tld is offered", + "format": "currency-micro-unit", + "type": "integer" + } + }, + "required": [ + "domain", + "available", + "definitive" + ] + }, + "DomainSuggestion": { + "properties": { + "domain": { + "description": "Suggested domain name", + "type": "string" + } + }, + "required": [ + "domain" + ] + }, + "DomainSummary": { + "properties": { + "authCode": { + "description": "Authorization code for transferring the Domain", + "type": "string" + }, + "contactAdmin": { + "$ref": "#/components/schemas/Contact" + }, + "contactBilling": { + "$ref": "#/components/schemas/Contact" + }, + "contactRegistrant": { + "$ref": "#/components/schemas/Contact" + }, + "contactTech": { + "$ref": "#/components/schemas/Contact" + }, + "createdAt": { + "description": "Date and time when this domain was created", + "type": "string" + }, + "deletedAt": { + "description": "Date and time when this domain was deleted", + "type": "string" + }, + "transferAwayEligibleAt": { + "description": "Date and time when this domain is eligible to transfer", + "type": "string" + }, + "domain": { + "description": "Name of the domain", + "type": "string" + }, + "domainId": { + "description": "Unique identifier for this Domain", + "format": "double", + "type": "number" + }, + "expirationProtected": { + "description": "Whether or not the domain is protected from expiration", + "type": "boolean" + }, + "expires": { + "description": "Date and time when this domain will expire", + "type": "string" + }, + "exposeWhois": { + "description": "Whether or not the domain contact details should be shown in the WHOIS", + "type": "boolean" + }, + "holdRegistrar": { + "description": "Whether or not the domain is on-hold by the registrar", + "type": "boolean" + }, + "locked": { + "description": "Whether or not the domain is locked to prevent transfers", + "type": "boolean" + }, + "nameServers": { + "description": "Fully-qualified domain names for DNS servers", + "items": { + "format": "host-name", + "type": "string" + }, + "type": "array", + "nullable": true + }, + "privacy": { + "description": "Whether or not the domain has privacy protection", + "type": "boolean" + }, + "registrarCreatedAt": { + "type": "string", + "format": "iso-datetime", + "description": "Date and time when this domain was created by the registrar" + }, + "renewAuto": { + "description": "Whether or not the domain is configured to automatically renew", + "type": "boolean" + }, + "renewDeadline": { + "description": "Date the domain must renew on", + "type": "string" + }, + "renewable": { + "description": "Whether or not the domain is eligble for renewal based on status", + "type": "boolean" + }, + "status": { + "description": "Processing status of the domain
    \n
  • ACTIVE - All is well
  • \n
  • AWAITING* - System is waiting for the end-user to complete an action
  • \n
  • CANCELLED* - Domain has been cancelled, and may or may not be reclaimable
  • \n
  • CONFISCATED - Domain has been confiscated, usually for abuse, chargeback, or fraud
  • \n
  • DISABLED* - Domain has been disabled
  • \n
  • EXCLUDED* - Domain has been excluded from Firehose registration
  • \n
  • EXPIRED* - Domain has expired
  • \n
  • FAILED* - Domain has failed a required action, and the system is no longer retrying
  • \n
  • HELD* - Domain has been placed on hold, and likely requires intervention from Support
  • \n
  • LOCKED* - Domain has been locked, and likely requires intervention from Support
  • \n
  • PARKED* - Domain has been parked, and likely requires intervention from Support
  • \n
  • PENDING* - Domain is working its way through an automated workflow
  • \n
  • RESERVED* - Domain is reserved, and likely requires intervention from Support
  • \n
  • REVERTED - Domain has been reverted, and likely requires intervention from Support
  • \n
  • SUSPENDED* - Domain has been suspended, and likely requires intervention from Support
  • \n
  • TRANSFERRED* - Domain has been transferred out
  • \n
  • UNKNOWN - Domain is in an unknown state
  • \n
  • UNLOCKED* - Domain has been unlocked, and likely requires intervention from Support
  • \n
  • UNPARKED* - Domain has been unparked, and likely requires intervention from Support
  • \n
  • UPDATED* - Domain ownership has been transferred to another account
  • \n
", + "type": "string" + }, + "transferProtected": { + "description": "Whether or not the domain is protected from transfer", + "type": "boolean" + } + } } } } diff --git a/rust/domains-client/scripts/regenerate-spec.sh b/rust/domains-client/scripts/regenerate-spec.sh index e80f97a..7900626 100755 --- a/rust/domains-client/scripts/regenerate-spec.sh +++ b/rust/domains-client/scripts/regenerate-spec.sh @@ -3,8 +3,10 @@ # # Pipeline: # 1. Download the upstream GoDaddy Domains API spec (Swagger 2.0). -# 2. Trim to the GET /v1/domains/available + GET /v1/domains/suggest operations -# and the transitive closure of definitions they reference. +# 2. Trim to the GET /v1/domains (list owned domains), GET /v1/domains/available +# + GET /v1/domains/suggest operations, the /v1/domains/{domain}/records +# DNS-record operations, and the transitive closure of definitions they +# reference. # 3. Convert Swagger 2.0 -> OpenAPI 3.0 with `swagger2openapi` (Node, via npx). # # Run this ONLY when the upstream spec changes; the committed domains.oas3.json @@ -21,29 +23,136 @@ oas3="$openapi_dir/domains.oas3.json" echo "==> Downloading upstream Swagger 2.0 spec" curl -fsSL "https://developer.godaddy.com/swagger/swagger_domains.json" -o "$v2" -echo "==> Trimming to available + suggest (GET) and their definition closure" +echo "==> Trimming to domains list + available + suggest + DNS records and their definition closure" python3 - "$v2" "$trimmed_v2" <<'PY' -import json, sys +import copy, json, sys src, dst = sys.argv[1], sys.argv[2] d = json.load(open(src)) -KEEP = ["/v1/domains/available", "/v1/domains/suggest"] -OP_IDS = {"/v1/domains/available": "available", "/v1/domains/suggest": "suggest"} +# GET-only operations kept as-is (operationId pinned). `list` retrieves the +# Domains owned by the authenticated shopper; available/suggest are the +# availability endpoints. +AVAIL_OPS = { + "/v1/domains": "list", + "/v1/domains/available": "available", + "/v1/domains/suggest": "suggest", +} -paths = {} -for p in KEEP: - get = d["paths"][p]["get"] - get["operationId"] = OP_IDS[p] - get["produces"] = ["application/json"] # drop xml/js variants +# DNS record operations: keep every method these paths expose. The upstream +# Swagger documents `recordGet` only on the {type}/{name} path even though the +# gateway also routes GET on the bare and {type} paths (see the OAuth scope +# whitelist in gdcorp-domains/api-domain-data, api/oauthscopewhitelist.json); +# we synthesize those two GETs below so the CLI can list all / by-type records. +RECORD_PATHS = [ + "/v1/domains/{domain}/records", + "/v1/domains/{domain}/records/{type}", + "/v1/domains/{domain}/records/{type}/{name}", +] + +def only_2xx(op): # Keep only the 2xx response: the client needs the success type; non-2xx # surface via HTTP status + body. (Also avoids progenitor's single-error-type # constraint when the spec mixes Error + ErrorLimit across 4xx/5xx.) - get["responses"] = { - code: resp for code, resp in get.get("responses", {}).items() + op["responses"] = { + code: resp for code, resp in op.get("responses", {}).items() if str(code).startswith("2") } - paths[p] = {"get": get} + return op + +paths = {} + +# Availability + suggest (GET only). +for p, op_id in AVAIL_OPS.items(): + get = d["paths"][p]["get"] + get["operationId"] = op_id + get["produces"] = ["application/json"] # drop xml/js variants + paths[p] = {"get": only_2xx(get)} + +# DNS record ops: keep all documented methods, success responses only, and +# normalize content types (drop xml/js variants; request bodies stay JSON). +for p in RECORD_PATHS: + kept = {} + for method, op in d["paths"][p].items(): + if method not in ("get", "put", "post", "patch", "delete"): + continue # skip non-operation keys (e.g. shared "parameters") + op["produces"] = ["application/json"] + if any(prm.get("in") == "body" for prm in op.get("parameters", [])): + op["consumes"] = ["application/json"] + kept[method] = only_2xx(op) + paths[p] = kept + +# Synthesize GET-all and GET-by-type from the documented recordGet operation: +# same DNSRecord-array success response, fewer path params, no offset/limit. +record_get = d["paths"]["/v1/domains/{domain}/records/{type}/{name}"]["get"] + +def synth_get(keep_param_names, op_id): + op = copy.deepcopy(record_get) + op["operationId"] = op_id + op["produces"] = ["application/json"] + # Keep the named path params plus any header params (e.g. the optional + # X-Shopper-Id the record routes accept) so the synthesized ops mirror what + # the route supports; drop the query params (offset/limit) that only apply to + # the type+name GET. + op["parameters"] = [ + prm for prm in op.get("parameters", []) + if prm.get("in") == "header" or prm.get("name") in keep_param_names + ] + return only_2xx(op) + +paths["/v1/domains/{domain}/records"]["get"] = synth_get({"domain"}, "recordGetAll") +paths["/v1/domains/{domain}/records/{type}"]["get"] = synth_get( + {"domain", "type"}, "recordGetByType" +) + +# String `format`s that typify maps to *external* crates (date-time/date -> +# chrono, uuid -> uuid). We don't depend on those (and deliberately keep this +# crate's dependency stack lean — see Cargo.toml), and the CLI only passes these +# values straight through to JSON output, so drop the format and let them +# generate as plain `String` (the RFC 3339 / UUID text is unchanged on the wire). +EXTERNAL_FORMATS = {"date", "date-time", "uuid", "partial-date-time"} + +def strip_external_formats(obj): + if isinstance(obj, dict): + if obj.get("type") == "string" and obj.get("format") in EXTERNAL_FORMATS: + obj.pop("format") + for v in obj.values(): + strip_external_formats(v) + elif isinstance(obj, list): + for v in obj: + strip_external_formats(v) + +# Read-only response definitions the CLI only deserializes-and-reprints. The +# published spec over-promises here: it marks fields `required` that the live API +# routinely omits (e.g. DomainSummary.contactRegistrant / renewDeadline on +# cancelled/pending domains) and types `nameServers` as a non-null array while +# the API returns JSON `null`. Relax these to tolerant readers. +# +# The complement — types the CLI *constructs* (request bodies) or reads via +# non-optional fields (e.g. `if available { … }`) — must stay strict, or call +# sites break. Everything not listed here is relaxed. +# NB: these are the *spec* definition names (upper-case `DNS…`), not the +# camel-cased Rust type names progenitor emits (`DnsRecord`). +STRICT_DEFS = { + "DomainAvailableResponse", # `available`/`suggest` read non-optional fields + "DomainSuggestion", + "DNSRecord", # built by `dns add` + "DNSRecordCreateType", + "DNSRecordCreateTypeName", # built by `dns set` + "ArrayOfDNSRecord", # `dns add` request body +} + +def relax_definition(defn): + # Drop `required` so every property generates as `Option` (missing -> None), + # and mark array properties `x-nullable` so an explicit `null` deserializes to + # `None` instead of erroring (`#[serde(default)]` alone only covers a *missing* + # key, not a present-but-null one). + if not isinstance(defn, dict): + return + defn.pop("required", None) + for pschema in (defn.get("properties") or {}).values(): + if isinstance(pschema, dict) and pschema.get("type") == "array": + pschema["x-nullable"] = True def dedup_enums(obj): # typify cannot build a Rust enum from case-only duplicates @@ -89,7 +198,7 @@ while stack: out = { "swagger": "2.0", - "info": {"title": "GoDaddy Domains API (availability subset)", + "info": {"title": "GoDaddy Domains API (domains list + availability + DNS records subset)", "version": d.get("info", {}).get("version", "1.0.0")}, "host": d.get("host", "api.ote-godaddy.com"), "basePath": d.get("basePath", "/"), @@ -97,6 +206,11 @@ out = { "paths": paths, "definitions": {n: d["definitions"][n] for n in sorted(needed) if n in d["definitions"]}, } +for name, defn in out["definitions"].items(): + if name not in STRICT_DEFS: + relax_definition(defn) + +strip_external_formats(out) json.dump(out, open(dst, "w"), indent=2) print(f" kept {len(paths)} paths, {len(out['definitions'])} definitions") PY diff --git a/rust/domains-client/src/lib.rs b/rust/domains-client/src/lib.rs index 199a1a9..bd10160 100644 --- a/rust/domains-client/src/lib.rs +++ b/rust/domains-client/src/lib.rs @@ -1,4 +1,4 @@ -//! GoDaddy Domains API client (availability + suggest). +//! GoDaddy Domains API client (domains list + availability + suggest + DNS records). //! //! The contents of this crate are **generated** by `progenitor` at build time //! from the vendored OpenAPI 3.0 spec (`openapi/domains.oas3.json`). Construct @@ -62,6 +62,7 @@ pub fn client_with_auth( #[cfg(test)] mod tests { use super::*; + use httpmock::Method::PATCH; // not re-exported by the prelude (unlike GET/PUT/DELETE) use httpmock::prelude::*; use serde_json::json; @@ -210,4 +211,233 @@ mod tests { let domains: Vec<&str> = suggestions.iter().map(|s| s.domain.as_str()).collect(); assert_eq!(domains, ["coffeehouse.com", "bestcoffee.com"]); } + + #[tokio::test] + async fn list_tolerates_sparse_payloads() { + // The published spec marks fields like `contactRegistrant`/`renewDeadline` + // required and types `nameServers` as a non-null array, but the live API + // omits the former and returns `nameServers: null` for many domains + // (cancelled/pending). The generated `DomainSummary` must read these + // without erroring. Payload mirrors a real `GET /v1/domains` response. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/domains"); + then.status(200).json_body(json!([ + { + "createdAt": "2021-09-24T15:08:06.000Z", + "deletedAt": "2024-11-05T02:30:31.000Z", + "domain": "blahblahblah253.com", + "domainId": 21605119, + "expirationProtected": true, + "expires": "2024-09-24T15:08:06.000Z", + "exposeWhois": false, + "holdRegistrar": false, + "locked": true, + "nameServers": null, + "privacy": true, + "renewAuto": false, + "renewable": false, + "status": "CANCELLED", + "transferProtected": true + }, + { + "createdAt": "2020-10-27T13:40:15.463Z", + "domain": "dullreferenceexception.me", + "domainId": 21507912, + "expirationProtected": false, + "exposeWhois": false, + "holdRegistrar": false, + "locked": false, + "nameServers": null, + "privacy": false, + "renewAuto": false, + "renewable": false, + "status": "PENDING_DNS_ACTIVE", + "transferProtected": false + } + ])); + }) + .await; + + let body = client_for(&server) + .list() + .send() + .await + .expect("sparse list payload parses") + .into_inner(); + + mock.assert_async().await; + assert_eq!(body.len(), 2); + } + + // --- DNS records --------------------------------------------------------- + // + // These guard the spec-generated record operations: the HTTP method + path + // (including the `{domain}`/`{type}`/`{name}` path segments and the + // synthesized list-all GET), the JSON request bodies the builder serializes, + // and response parsing. They run entirely offline against a mock server. + + fn client_for(server: &MockServer) -> Client { + client_with_auth( + &server.base_url(), + "Bearer tok", + "godaddy-cli/test", + "req-rec", + ) + .expect("build client") + } + + #[tokio::test] + async fn record_get_all_lists_every_record() { + // No type/name -> the synthesized GET on the bare `/records` path. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/domains/example.com/records") + .header("authorization", "Bearer tok"); + then.status(200).json_body(json!([ + { "type": "A", "name": "www", "data": "1.2.3.4", "ttl": 600 }, + { "type": "TXT", "name": "@", "data": "v=spf1 -all" } + ])); + }) + .await; + + let records = client_for(&server) + .record_get_all() + .domain("example.com") + .send() + .await + .expect("request succeeds") + .into_inner(); + + mock.assert_async().await; + assert_eq!(records.len(), 2); + assert_eq!(records[0].type_, types::DnsRecordType::A); + assert_eq!(records[0].name, "www"); + assert_eq!(records[0].data, "1.2.3.4"); + assert_eq!(records[0].ttl, Some(600)); + assert_eq!(records[1].type_, types::DnsRecordType::Txt); + } + + #[tokio::test] + async fn record_get_sends_type_name_and_pagination() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/domains/example.com/records/A/www") + .query_param("limit", "10") + .query_param("offset", "5"); + then.status(200) + .json_body(json!([{ "type": "A", "name": "www", "data": "1.2.3.4" }])); + }) + .await; + + let records = client_for(&server) + .record_get() + .domain("example.com") + .type_("A") + .name("www") + .limit(10) + .offset(5) + .send() + .await + .expect("request succeeds") + .into_inner(); + + mock.assert_async().await; + assert_eq!(records.len(), 1); + assert_eq!(records[0].data, "1.2.3.4"); + } + + #[tokio::test] + async fn record_add_patches_a_record_array() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PATCH) + .path("/v1/domains/example.com/records") + .json_body(json!([{ "data": "1.2.3.4", "name": "www", "type": "A" }])); + then.status(200); + }) + .await; + + client_for(&server) + .record_add() + .domain("example.com") + .body(vec![types::DnsRecord { + data: "1.2.3.4".to_string(), + name: "www".to_string(), + type_: types::DnsRecordType::A, + ttl: None, + priority: None, + port: None, + weight: None, + protocol: None, + service: None, + }]) + .send() + .await + .expect("request succeeds"); + + mock.assert_async().await; + } + + #[tokio::test] + async fn record_replace_type_name_puts_the_record_set() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PUT) + .path("/v1/domains/example.com/records/A/www") + .json_body(json!([{ "data": "5.6.7.8", "ttl": 600 }])); + then.status(200); + }) + .await; + + client_for(&server) + .record_replace_type_name() + .domain("example.com") + .type_("A") + .name("www") + .body(vec![types::DnsRecordCreateTypeName { + data: "5.6.7.8".to_string(), + ttl: Some(600), + priority: None, + port: None, + weight: None, + protocol: None, + service: None, + }]) + .send() + .await + .expect("request succeeds"); + + mock.assert_async().await; + } + + #[tokio::test] + async fn record_delete_type_name_issues_delete_and_accepts_204() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(DELETE) + .path("/v1/domains/example.com/records/A/www"); + then.status(204); + }) + .await; + + client_for(&server) + .record_delete_type_name() + .domain("example.com") + .type_("A") + .name("www") + .send() + .await + .expect("request succeeds"); + + mock.assert_async().await; + } } diff --git a/rust/src/api_explorer/mod.rs b/rust/src/api_explorer/mod.rs index aeedac3..7b4f02d 100644 --- a/rust/src/api_explorer/mod.rs +++ b/rust/src/api_explorer/mod.rs @@ -7,6 +7,46 @@ use cli_engine::{ use serde::Deserialize; use serde_json::{Value, json}; +use crate::output_schema::output_schema; + +output_schema!(ApiDomain { + "domain": "string"; + "title": "string"; + "description": "string", optional; + "endpoints": "number"; + "baseUrl": "string"; +}); + +output_schema!(ApiEndpoint { + "domain": "string"; + "operationId": "string"; + "method": "string"; + "path": "string"; + "summary": "string", optional; +}); + +// `api endpoint list --domain X` lists endpoints within one domain, so each row +// omits the (redundant) domain field that the cross-domain `api search` emits. +output_schema!(ApiDomainEndpoint { + "operationId": "string"; + "method": "string"; + "path": "string"; + "summary": "string", optional; +}); + +output_schema!(ApiOperation { + "domain": "string"; + "operationId": "string"; + "method": "string"; + "path": "string"; + "summary": "string", optional; + "description": "string", optional; + "parameters": "[]object"; + "requestBody": "object", optional; + "responses": "object"; + "scopes": "[]string"; +}); + // --------------------------------------------------------------------------- // Catalog types // --------------------------------------------------------------------------- @@ -201,58 +241,30 @@ pub fn module() -> Module { "api", "Explore and call GoDaddy API endpoints", )) - .with_command(list_command()) + .with_group( + RuntimeGroupSpec::new(GroupSpec::new("domain", "Browse API domains")) + .with_command(domain_list_command()), + ) + .with_group( + RuntimeGroupSpec::new(GroupSpec::new("endpoint", "Browse API endpoints")) + .with_command(endpoint_list_command()), + ) .with_command(describe_command()) .with_command(search_command()) .with_command(call_command()) }) } -fn list_command() -> RuntimeCommandSpec { +fn domain_list_command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_with_context( CommandSpec::new("list", "List all API domains") .with_system("api") .with_tier(Tier::Read) .no_auth(true) .with_default_fields("domain,title,endpoints,baseUrl") - .with_arg( - clap::Arg::new("domain") - .long("domain") - .value_name("DOMAIN") - .help("Show endpoints within a specific domain"), - ), - |ctx| async move { + .with_output_schema::(), + |_ctx| async move { let catalog = catalog(); - if let Some(domain_filter) = ctx.args.get("domain").and_then(|v| v.as_str()) { - let domain = catalog - .iter() - .find(|d| d.name == domain_filter) - .ok_or_else(|| { - cli_engine::CliCoreError::message(format!( - "domain '{domain_filter}' not found" - )) - })?; - let endpoints: Vec = domain - .endpoints - .iter() - .map(|ep| { - json!({ - "operationId": ep.operation_id, - "method": ep.method, - "path": ep.path, - "summary": ep.summary, - }) - }) - .collect(); - return Ok(CommandResult::new(json!(endpoints)).with_next_actions(vec![ - NextAction::new( - "api describe ", - "Get full details for an endpoint", - ) - .with_param("operationId", NextActionParam::required()), - ])); - } - let domains: Vec = catalog .iter() .map(|d| { @@ -267,7 +279,7 @@ fn list_command() -> RuntimeCommandSpec { .collect(); Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ NextAction::new( - "api list --domain ", + "api endpoint list --domain ", "List endpoints in a specific domain", ) .with_param("domain", NextActionParam::required()), @@ -277,6 +289,57 @@ fn list_command() -> RuntimeCommandSpec { ) } +fn endpoint_list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("list", "List endpoints within an API domain") + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true) + .with_default_fields("operationId,method,path,summary") + .with_output_schema::() + .with_arg( + clap::Arg::new("domain") + .long("domain") + .value_name("DOMAIN") + .required(true) + .help("API domain whose endpoints to list (see `api domain list`)"), + ), + |ctx| async move { + let catalog = catalog(); + let domain_filter = ctx + .args + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let domain = catalog + .iter() + .find(|d| d.name == domain_filter) + .ok_or_else(|| { + cli_engine::CliCoreError::message(format!("domain '{domain_filter}' not found")) + })?; + let endpoints: Vec = domain + .endpoints + .iter() + .map(|ep| { + json!({ + "operationId": ep.operation_id, + "method": ep.method, + "path": ep.path, + "summary": ep.summary, + }) + }) + .collect(); + Ok(CommandResult::new(json!(endpoints)).with_next_actions(vec![ + NextAction::new( + "api describe ", + "Get full details for an endpoint", + ) + .with_param("operationId", NextActionParam::required()), + ])) + }, + ) +} + fn describe_command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_with_context( CommandSpec::new( @@ -286,6 +349,7 @@ fn describe_command() -> RuntimeCommandSpec { .with_system("api") .with_tier(Tier::Read) .no_auth(true) + .with_output_schema::() .with_arg( clap::Arg::new("endpoint") .value_name("ENDPOINT") @@ -333,6 +397,7 @@ fn search_command() -> RuntimeCommandSpec { .with_tier(Tier::Read) .no_auth(true) .with_default_fields("domain,method,path,summary") + .with_output_schema::() .with_arg( clap::Arg::new("query") .value_name("QUERY") diff --git a/rust/src/application/commands/mod.rs b/rust/src/application/commands/mod.rs index 3262571..3657ab5 100644 --- a/rust/src/application/commands/mod.rs +++ b/rust/src/application/commands/mod.rs @@ -5,6 +5,91 @@ use cli_engine::{ use serde_json::json; use crate::application::client::{ApplicationClient, api_url_for_env}; +use crate::output_schema::output_schema; + +output_schema!(ApplicationSummary { + "id": "string"; + "name": "string"; + "label": "string", optional; + "description": "string", optional; + "status": "string"; + "url": "string", optional; + "proxyUrl": "string", optional; +}); + +output_schema!(ApplicationCredentials { + "id": "string"; + "clientId": "string"; + "clientSecret": "string"; + "name": "string"; + "label": "string", optional; + "description": "string", optional; + "status": "string"; + "url": "string", optional; + "proxyUrl": "string", optional; + "authorizationScopes": "[]string"; + "secret": "string", optional; + "publicKey": "string", optional; +}); + +output_schema!(ApplicationUpdate { + "id": "string"; + "clientId": "string"; + "name": "string"; + "label": "string", optional; + "description": "string", optional; + "status": "string"; + "url": "string", optional; + "proxyUrl": "string", optional; + "authorizationScopes": "[]string"; +}); + +output_schema!(ApplicationRef { + "id": "string"; +}); + +output_schema!(ApplicationArchive { + "id": "string"; + "name": "string"; + "label": "string", optional; + "status": "string"; + "createdAt": "string"; + "archivedAt": "string"; +}); + +output_schema!(ApplicationRelease { + "id": "string"; + "version": "string"; + "description": "string", optional; + "createdAt": "string"; +}); + +output_schema!(ValidationResult { + "valid": "bool"; + "path": "string"; +}); + +output_schema!(ConfigAction { + "name": "string"; + "url": "string"; +}); + +output_schema!(ConfigSubscription { + "name": "string"; + "url": "string"; + "events": "[]string"; +}); + +output_schema!(ExtensionHandle { + "name": "string"; + "handle": "string"; + "type": "string"; +}); + +output_schema!(ExtensionBlocks { + "source": "string"; + "type": "string"; +}); async fn make_client(ctx: &cli_engine::CommandContext) -> cli_engine::Result { // Lazily resolve the credential; this triggers the auth flow only for @@ -44,7 +129,8 @@ fn list_command() -> RuntimeCommandSpec { CommandSpec::new("list", "List all applications") .with_system("applications") .with_tier(Tier::Read) - .with_default_fields("name,label,status"), + .with_default_fields("name,label,status") + .with_output_schema::(), |ctx| async move { let client = make_client(&ctx).await?; let data = client.list_applications().await.map_err(client_err)?; @@ -68,6 +154,7 @@ fn info_command() -> RuntimeCommandSpec { CommandSpec::new("info", "Get application details") .with_system("applications") .with_tier(Tier::Read) + .with_output_schema::() .with_arg( clap::Arg::new("name") .long("name") @@ -113,6 +200,7 @@ fn init_command() -> RuntimeCommandSpec { CommandSpec::new("init", "Create and initialize a new application") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .with_arg( clap::Arg::new("name") .long("name") @@ -233,6 +321,7 @@ fn validate_command() -> RuntimeCommandSpec { CommandSpec::new("validate", "Validate godaddy.toml config") .with_system("applications") .with_tier(Tier::Read) + .with_output_schema::() .no_auth(true) .with_arg( clap::Arg::new("config") @@ -265,6 +354,7 @@ fn update_command() -> RuntimeCommandSpec { CommandSpec::new("update", "Update an application") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .with_arg( clap::Arg::new("id") .long("id") @@ -339,6 +429,7 @@ fn enable_command() -> RuntimeCommandSpec { CommandSpec::new("enable", "Enable an application on a store") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .with_arg( clap::Arg::new("name") .value_name("NAME") @@ -405,6 +496,7 @@ fn disable_command() -> RuntimeCommandSpec { CommandSpec::new("disable", "Disable an application on a store") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .with_arg( clap::Arg::new("name") .value_name("NAME") @@ -473,6 +565,7 @@ fn archive_command() -> RuntimeCommandSpec { CommandSpec::new("archive", "Archive an application") .with_system("applications") .with_tier(Tier::Destructive) + .with_output_schema::() .with_arg( clap::Arg::new("name") .value_name("NAME") @@ -507,6 +600,7 @@ fn release_command() -> RuntimeCommandSpec { CommandSpec::new("release", "Create a new application release") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .with_arg( clap::Arg::new("application-id") .long("application-id") @@ -826,6 +920,7 @@ pub fn add_group() -> RuntimeGroupSpec { CommandSpec::new("action", "Add an action to godaddy.toml") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .no_auth(true) .with_arg( clap::Arg::new("name") @@ -858,6 +953,7 @@ pub fn add_group() -> RuntimeGroupSpec { CommandSpec::new("subscription", "Add a webhook subscription to godaddy.toml") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .no_auth(true) .with_arg( clap::Arg::new("name") @@ -922,6 +1018,7 @@ pub fn add_extension_group() -> RuntimeGroupSpec { CommandSpec::new("embed", "Add an embed extension") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .no_auth(true) .with_arg( clap::Arg::new("name") @@ -972,6 +1069,7 @@ pub fn add_extension_group() -> RuntimeGroupSpec { CommandSpec::new("checkout", "Add a checkout extension") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .no_auth(true) .with_arg( clap::Arg::new("name") @@ -1022,6 +1120,7 @@ pub fn add_extension_group() -> RuntimeGroupSpec { CommandSpec::new("blocks", "Add a blocks extension") .with_system("applications") .with_tier(Tier::Mutate) + .with_output_schema::() .no_auth(true) .with_arg( clap::Arg::new("source") diff --git a/rust/src/dns/mod.rs b/rust/src/dns/mod.rs new file mode 100644 index 0000000..b7156f9 --- /dev/null +++ b/rust/src/dns/mod.rs @@ -0,0 +1,687 @@ +//! `gddy dns` — manage a domain's DNS records (A, AAAA, CNAME, MX, TXT, …). +//! +//! These are the record operations of the same GoDaddy Domains API the +//! [`crate::domain`] commands use (paths under `/v1/domains/{domain}/records`), +//! served by the typed, spec-generated [`domains_client`] crate. Auth, the +//! base-URL/environment resolution, and the sso-key/Bearer scheme selection are +//! shared with `domain` via [`crate::domain::make_client`]. +//! +//! Reads (`list`) require the `domains.domain:read` scope; mutations (`add`, +//! `set`, `delete`) require `domains.dns:update`. `set` and `delete` are +//! [`Tier::Destructive`] — they overwrite or remove existing records — so +//! `--dry-run` short-circuits them with a preview, and they carry the engine's +//! global `--reason` flag for audit (`add` only appends, so it is `Mutate`). + +use cli_engine::{ + CliCoreError, CommandContext, CommandResult, CommandSpec, GroupSpec, Module, + RuntimeCommandSpec, RuntimeGroupSpec, Tier, +}; +use serde_json::{Value, json}; + +use crate::domain::{DOMAINS_DNS_UPDATE_SCOPE, DOMAINS_READ_SCOPE, make_client, string_list}; +use crate::output_schema::output_schema; + +use domains_client::types; + +// Output shapes for the mutating commands (their handlers emit these confirmation +// objects), registered so `--help`/`--schema` list the fields like the reads do. +output_schema!(DnsWriteResult { + "domain": "string"; + "type": "string"; + "name": "string"; + "records": "number"; + "action": "string"; +}); + +output_schema!(DnsDeleteResult { + "domain": "string"; + "type": "string"; + "name": "string"; + "deleted": "bool"; +}); + +/// The eight DNS record types the Domains API accepts, for help text and error +/// messages. Source of truth for the wire values is the generated +/// [`types::DnsRecordType`]. +const RECORD_TYPES: &str = "A, AAAA, CNAME, MX, NS, SOA, SRV, TXT"; + +fn arg_str(ctx: &CommandContext, key: &str) -> Option { + ctx.args + .get(key) + .and_then(|v| v.as_str()) + .map(str::to_owned) +} + +/// clap value-parser for `--type`: validate against the record-type set and +/// return the canonical upper-case wire string. +/// +/// Validating in clap (rather than the handler) means invalid input is rejected +/// at parse time — *before* cli-engine's `--dry-run`/auth short-circuits — so +/// e.g. `dns set … --type BOGUS --dry-run` fails instead of reporting success. +/// The handlers can then trust the value and feed it straight to the builders. +fn parse_type_arg(raw: &str) -> Result { + let upper = raw.to_ascii_uppercase(); + types::DnsRecordType::try_from(upper.as_str()) + .map(|_| upper) + .map_err(|_| format!("invalid record type {raw:?}; expected one of {RECORD_TYPES}")) +} + +/// Like [`parse_type_arg`], but for `dns delete`: rejects the registry-managed +/// NS/SOA types the records API can't delete (`recordDeleteTypeName`'s type set +/// omits them), with a clear reason — at parse time, so +/// `dns delete … --type NS --dry-run` fails too. +fn parse_deletable_type_arg(raw: &str) -> Result { + let upper = raw.to_ascii_uppercase(); + if types::RecordDeleteTypeNameType::try_from(upper.as_str()).is_ok() { + return Ok(upper); + } + // Distinguish "valid type, just not deletable" from "not a type at all". + if types::DnsRecordType::try_from(upper.as_str()).is_ok() { + Err(format!( + "{upper} records can't be deleted (NS and SOA records are managed by GoDaddy); \ + deletable types: A, AAAA, CNAME, MX, SRV, TXT" + )) + } else { + Err(format!( + "invalid record type {raw:?}; expected one of A, AAAA, CNAME, MX, SRV, TXT" + )) + } +} + +/// Optional record fields shared by `add` and `set`. +struct RecordOptions { + ttl: Option, + priority: Option, + port: Option, + weight: Option, + protocol: Option, + service: Option, +} + +impl RecordOptions { + fn from_ctx(ctx: &CommandContext) -> Self { + let as_i64 = |key: &str| ctx.args.get(key).and_then(Value::as_i64); + RecordOptions { + ttl: as_i64("ttl"), + priority: as_i64("priority"), + // The parser bounds --port to 1..=65535, so a non-zero value always + // fits NonZeroU64; `new` keeps it total without an unwrap. + port: as_i64("port") + .and_then(|p| u64::try_from(p).ok()) + .and_then(std::num::NonZeroU64::new), + weight: as_i64("weight"), + protocol: arg_str(ctx, "protocol"), + service: arg_str(ctx, "service"), + } + } +} + +/// Build full `DnsRecord`s (type + name + data) for `add` — one per `--data`. +fn dns_records( + name: &str, + ty: types::DnsRecordType, + data: &[String], + opts: &RecordOptions, +) -> Vec { + data.iter() + .map(|d| types::DnsRecord { + data: d.clone(), + name: name.to_owned(), + type_: ty, + ttl: opts.ttl, + priority: opts.priority, + port: opts.port, + weight: opts.weight, + protocol: opts.protocol.clone(), + service: opts.service.clone(), + }) + .collect() +} + +/// Build the type+name-relative record bodies for `set` (the type and name come +/// from the URL path, so they are omitted here) — one per `--data`. +fn create_type_name_records( + data: &[String], + opts: &RecordOptions, +) -> Vec { + data.iter() + .map(|d| types::DnsRecordCreateTypeName { + data: d.clone(), + ttl: opts.ttl, + priority: opts.priority, + port: opts.port, + weight: opts.weight, + protocol: opts.protocol.clone(), + service: opts.service.clone(), + }) + .collect() +} + +/// Shared flags for the mutating commands (`add`/`set`): required type/name and +/// the repeatable `--data`, plus the optional record fields. +fn with_record_write_args(spec: CommandSpec) -> CommandSpec { + spec.with_arg( + clap::Arg::new("domain") + .value_name("DOMAIN") + .required(true) + .help("Domain whose records to modify (e.g. example.com)"), + ) + .with_arg( + clap::Arg::new("type") + .long("type") + .value_name("TYPE") + .required(true) + .value_parser(parse_type_arg) + .help("Record type (one of A, AAAA, CNAME, MX, NS, SOA, SRV, TXT)"), + ) + .with_arg( + clap::Arg::new("name") + .long("name") + .value_name("NAME") + .required(true) + .help("Record name relative to the domain (e.g. www, @ for the apex)"), + ) + .with_arg( + clap::Arg::new("data") + .long("data") + .value_name("VALUE") + .required(true) + .action(clap::ArgAction::Append) + .help("Record value (repeatable for multiple records on the same name)"), + ) + .with_arg( + clap::Arg::new("ttl") + .long("ttl") + .value_name("SECONDS") + .value_parser(clap::value_parser!(i64).range(1..)) + .help("Time-to-live in seconds"), + ) + .with_arg( + clap::Arg::new("priority") + .long("priority") + .value_name("N") + .value_parser(clap::value_parser!(i64).range(0..)) + .help("Record priority (MX and SRV only)"), + ) + .with_arg( + clap::Arg::new("port") + .long("port") + .value_name("PORT") + .value_parser(clap::value_parser!(i64).range(1..=65535)) + .help("Service port (SRV only)"), + ) + .with_arg( + clap::Arg::new("weight") + .long("weight") + .value_name("N") + .value_parser(clap::value_parser!(i64).range(0..)) + .help("Record weight (SRV only)"), + ) + .with_arg( + clap::Arg::new("protocol") + .long("protocol") + .value_name("PROTO") + .help("Service protocol (SRV only)"), + ) + .with_arg( + clap::Arg::new("service") + .long("service") + .value_name("SERVICE") + .help("Service type (SRV only)"), + ) +} + +pub fn module() -> Module { + Module::new("DNS", |_ctx| { + RuntimeGroupSpec::new(GroupSpec::new("dns", "Manage a domain's DNS records")) + // --- list ------------------------------------------------------- + .with_command(RuntimeCommandSpec::new_with_context( + CommandSpec::new("list", "List DNS records for a domain") + .with_system("domain") + .with_tier(Tier::Read) + .with_default_fields("type,name,data,ttl") + .with_json_schema::() + .with_scopes(&[DOMAINS_READ_SCOPE]) + .with_arg( + clap::Arg::new("domain") + .value_name("DOMAIN") + .required(true) + .help("Domain whose records to list (e.g. example.com)"), + ) + .with_arg( + clap::Arg::new("type") + .long("type") + .value_name("TYPE") + .value_parser(parse_type_arg) + .help( + "Only records of this type (A, AAAA, CNAME, MX, NS, SOA, SRV, TXT)", + ), + ) + .with_arg( + clap::Arg::new("name") + .long("name") + .value_name("NAME") + // The API only filters by name within a type, so clap + // enforces `--name` requires `--type` at parse time. + .requires("type") + .help("Only records with this name (requires --type)"), + ), + // Output pagination is the engine's job: the global, client-side + // `--limit`/`--offset` flags slice the returned list. We don't + // expose the API's server-side offset/limit (it would double-skip + // against the client-side `--offset`), so `record_get` is called + // without them. + |ctx| async move { + let domain = arg_str(&ctx, "domain").unwrap_or_default(); + // `--type` is validated + upper-cased by clap's value parser, + // and `--name` requires `--type`, so these can be used as-is. + let type_opt = arg_str(&ctx, "type"); + let name_opt = arg_str(&ctx, "name"); + + let client = make_client(&ctx).await?; + let records = match (type_opt.as_deref(), name_opt.as_deref()) { + (None, _) => client + .record_get_all() + .domain(domain.as_str()) + .send() + .await + .map_err(|e| { + CliCoreError::message(format!("listing DNS records failed: {e}")) + })? + .into_inner(), + (Some(record_type), None) => client + .record_get_by_type() + .domain(domain.as_str()) + .type_(record_type) + .send() + .await + .map_err(|e| { + CliCoreError::message(format!("listing DNS records failed: {e}")) + })? + .into_inner(), + (Some(record_type), Some(name)) => client + .record_get() + .domain(domain.as_str()) + .type_(record_type) + .name(name) + .send() + .await + .map_err(|e| { + CliCoreError::message(format!("listing DNS records failed: {e}")) + })? + .into_inner(), + }; + + // Serialize each record to an object so `--fields` and the + // default-field projection have `type`/`name`/`data`/`ttl`. + let out: Vec = records + .iter() + .map(serde_json::to_value) + .collect::>() + .map_err(|e| { + CliCoreError::message(format!("failed to serialize DNS records: {e}")) + })?; + Ok(CommandResult::new(json!(out))) + }, + )) + // --- add -------------------------------------------------------- + .with_command(RuntimeCommandSpec::new_with_context( + with_record_write_args( + CommandSpec::new( + "add", + "Add DNS records to a domain (appends; non-destructive)", + ) + .with_system("domain") + .with_tier(Tier::Mutate) + .with_default_fields("domain,type,name,records") + .with_output_schema::() + .with_scopes(&[DOMAINS_DNS_UPDATE_SCOPE]), + ), + |ctx| async move { + let domain = arg_str(&ctx, "domain").unwrap_or_default(); + // `--type` is validated + upper-cased by clap's value parser. + let record_type = arg_str(&ctx, "type").unwrap_or_default(); + let name = arg_str(&ctx, "name").unwrap_or_default(); + let data = string_list(&ctx, "data"); + let ty = types::DnsRecordType::try_from(record_type.as_str()) + .expect("--type value parser guarantees a valid record type"); + let opts = RecordOptions::from_ctx(&ctx); + let records = dns_records(&name, ty, &data, &opts); + let count = records.len(); + + let client = make_client(&ctx).await?; + client + .record_add() + .domain(domain.as_str()) + .body(records) + .send() + .await + .map_err(|e| { + CliCoreError::message(format!("adding DNS records failed: {e}")) + })?; + + Ok(CommandResult::new(json!({ + "domain": domain, + "type": record_type, + "name": name, + "records": count, + "action": "add", + }))) + }, + )) + // --- set -------------------------------------------------------- + .with_command(RuntimeCommandSpec::new_with_context( + with_record_write_args( + CommandSpec::new( + "set", + "Replace all records for a type+name (destructive: overwrites existing)", + ) + .with_system("domain") + .with_tier(Tier::Destructive) + .with_default_fields("domain,type,name,records") + .with_output_schema::() + .with_scopes(&[DOMAINS_DNS_UPDATE_SCOPE]), + ), + |ctx| async move { + let domain = arg_str(&ctx, "domain").unwrap_or_default(); + // `--type` is validated + upper-cased by clap's value parser. + let record_type = arg_str(&ctx, "type").unwrap_or_default(); + let name = arg_str(&ctx, "name").unwrap_or_default(); + let data = string_list(&ctx, "data"); + let opts = RecordOptions::from_ctx(&ctx); + let records = create_type_name_records(&data, &opts); + let count = records.len(); + + let client = make_client(&ctx).await?; + client + .record_replace_type_name() + .domain(domain.as_str()) + .type_(record_type.as_str()) + .name(name.as_str()) + .body(records) + .send() + .await + .map_err(|e| { + CliCoreError::message(format!("replacing DNS records failed: {e}")) + })?; + + Ok(CommandResult::new(json!({ + "domain": domain, + "type": record_type, + "name": name, + "records": count, + "action": "replace", + }))) + }, + )) + // --- delete ----------------------------------------------------- + .with_command(RuntimeCommandSpec::new_with_context( + CommandSpec::new( + "delete", + "Delete all records for a type+name (destructive: removes existing)", + ) + .with_system("domain") + .with_tier(Tier::Destructive) + .with_default_fields("domain,type,name,deleted") + .with_output_schema::() + .with_scopes(&[DOMAINS_DNS_UPDATE_SCOPE]) + .with_arg( + clap::Arg::new("domain") + .value_name("DOMAIN") + .required(true) + .help("Domain whose records to delete (e.g. example.com)"), + ) + .with_arg( + clap::Arg::new("type") + .long("type") + .value_name("TYPE") + .required(true) + .value_parser(parse_deletable_type_arg) + .help("Record type (one of A, AAAA, CNAME, MX, SRV, TXT)"), + ) + .with_arg( + clap::Arg::new("name") + .long("name") + .value_name("NAME") + .required(true) + .help("Record name relative to the domain (e.g. www)"), + ), + |ctx| async move { + let domain = arg_str(&ctx, "domain").unwrap_or_default(); + // `--type` is validated (and NS/SOA rejected) + upper-cased by + // clap's value parser, so it converts to the delete enum cleanly. + let record_type = arg_str(&ctx, "type").unwrap_or_default(); + let name = arg_str(&ctx, "name").unwrap_or_default(); + + let client = make_client(&ctx).await?; + client + .record_delete_type_name() + .domain(domain.as_str()) + .type_(record_type.as_str()) + .name(name.as_str()) + .send() + .await + .map_err(|e| { + CliCoreError::message(format!("deleting DNS records failed: {e}")) + })?; + + Ok(CommandResult::new(json!({ + "domain": domain, + "type": record_type, + "name": name, + "deleted": true, + }))) + }, + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use cli_engine::{Cli, CliConfig}; + + #[test] + fn parse_type_arg_validates_and_uppercases() { + assert_eq!(parse_type_arg("aaaa").expect("valid"), "AAAA"); + assert_eq!(parse_type_arg("Cname").expect("valid"), "CNAME"); + let err = parse_type_arg("bogus").expect_err("should reject"); + assert!(err.contains("invalid record type"), "got: {err}"); + assert!(err.contains("AAAA"), "lists valid types: {err}"); + } + + #[test] + fn parse_deletable_type_arg_rejects_ns_and_soa_with_clear_message() { + assert_eq!(parse_deletable_type_arg("a").expect("valid"), "A"); + assert!(parse_deletable_type_arg("TXT").is_ok()); + // NS/SOA are valid types but GoDaddy-managed — rejected as non-deletable + // (not the generated client's opaque conversion error). + for ty in ["NS", "soa"] { + let err = parse_deletable_type_arg(ty).expect_err("should reject"); + assert!(err.contains("managed by GoDaddy"), "got: {err}"); + assert!( + !err.contains("RecordDeleteTypeNameType"), + "leaked type name: {err}" + ); + } + // A non-type is rejected as invalid, not as non-deletable. + let err = parse_deletable_type_arg("bogus").expect_err("should reject"); + assert!(err.contains("invalid record type"), "got: {err}"); + } + + /// Type/flag validation lives in clap value-parsers, so invalid input is + /// rejected at parse time — before auth or `--dry-run` can short-circuit. + /// (Regression guard: these previously validated only in the handler, so + /// `--dry-run` reported success and `--name` without `--type` reached auth.) + #[tokio::test] + async fn invalid_dns_input_is_rejected_before_auth_or_dry_run() { + let cli = || { + Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_default_auth_provider("godaddy") + .with_module(module()), + ) + }; + let cases: [(&[&str], &str); 3] = [ + // Undeletable type — rejected with the GoDaddy reason, not an auth error. + ( + &[ + "gddy", + "dns", + "delete", + "example.com", + "--type", + "NS", + "--name", + "@", + ], + "managed by GoDaddy", + ), + // Bogus type on a mutating command. + ( + &[ + "gddy", + "dns", + "set", + "example.com", + "--type", + "BOGUS", + "--name", + "@", + "--data", + "x", + ], + "invalid record type", + ), + // `--name` without `--type` on list. + ( + &["gddy", "dns", "list", "example.com", "--name", "www"], + "--type", + ), + ]; + for (args, needle) in cases { + let output = cli().run(args.iter().copied()).await; + assert_ne!( + output.exit_code, 0, + "{args:?} should fail: {}", + output.rendered + ); + assert!( + output.rendered.contains(needle), + "{args:?} expected {needle:?}, got: {}", + output.rendered + ); + } + } + + #[test] + fn dns_records_builds_one_per_data_value() { + let opts = RecordOptions { + ttl: Some(600), + priority: None, + port: None, + weight: None, + protocol: None, + service: None, + }; + let recs = dns_records( + "www", + types::DnsRecordType::A, + &["1.2.3.4".to_string(), "5.6.7.8".to_string()], + &opts, + ); + assert_eq!(recs.len(), 2); + assert_eq!(recs[0].name, "www"); + assert_eq!(recs[0].type_, types::DnsRecordType::A); + assert_eq!(recs[0].data, "1.2.3.4"); + assert_eq!(recs[0].ttl, Some(600)); + assert_eq!(recs[1].data, "5.6.7.8"); + } + + #[test] + fn create_type_name_records_omit_type_and_name() { + let opts = RecordOptions { + ttl: None, + priority: Some(10), + port: None, + weight: None, + protocol: None, + service: None, + }; + let recs = create_type_name_records(&["mail.example.com".to_string()], &opts); + assert_eq!(recs.len(), 1); + assert_eq!(recs[0].data, "mail.example.com"); + assert_eq!(recs[0].priority, Some(10)); + } + + /// Like the `domain` commands, the `dns` commands hit the Domains API and + /// must stay fail-closed: built with no auth provider registered, the + /// engine's default `AuthRequirement::Required` rejects them at credential + /// resolution (exit code 2) before any handler runs. Running each leaf also + /// exercises clap's command-tree construction (duplicate-subcommand panics + /// only surface in debug builds). + #[tokio::test] + async fn dns_commands_require_auth() { + const AUTH_FAILURE_EXIT: i32 = 2; + let cases: [&[&str]; 4] = [ + &["gddy", "dns", "list", "example.com", "--output", "json"], + &[ + "gddy", + "dns", + "add", + "example.com", + "--type", + "A", + "--name", + "www", + "--data", + "1.2.3.4", + "--output", + "json", + ], + &[ + "gddy", + "dns", + "set", + "example.com", + "--type", + "A", + "--name", + "www", + "--data", + "5.6.7.8", + "--reason", + "test", + "--output", + "json", + ], + &[ + "gddy", + "dns", + "delete", + "example.com", + "--type", + "A", + "--name", + "www", + "--reason", + "test", + "--output", + "json", + ], + ]; + for args in cases { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_default_auth_provider("godaddy") + .with_module(module()), + ); + let output = cli.run(args.iter().copied()).await; + assert_eq!( + output.exit_code, AUTH_FAILURE_EXIT, + "{args:?} must fail closed at auth resolution, got: {}", + output.rendered + ); + } + } +} diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index 96b2d75..f11dedd 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -28,7 +28,19 @@ const USER_AGENT: &str = concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")); /// Declared on the commands via [`CommandSpec::with_scopes`] so cli-engine's /// OAuth scope step-up mints a token carrying it. Ignored on the sso-key path /// (sso-key auth is unscoped). -const DOMAINS_READ_SCOPE: &str = "domains.domain:read"; +/// +/// Shared with the `dns` module: DNS record *reads* live under this same scope. +pub(crate) const DOMAINS_READ_SCOPE: &str = "domains.domain:read"; + +/// OAuth scope the DNS record *mutation* endpoints (PATCH/PUT/DELETE under +/// `/v1/domains/{domain}/records`) require. +/// +/// Source of truth (undocumented in the published Swagger spec): the same +/// `gdcorp-domains/api-domain-data` `api/oauthscopewhitelist.json` whitelist — +/// every `PATCH`/`PUT`/`DELETE` on `v1_domains__records*` is listed under +/// `domains.dns:update` (reads stay under [`DOMAINS_READ_SCOPE`]). Consumed by +/// the `dns` add/set/delete commands. +pub(crate) const DOMAINS_DNS_UPDATE_SCOPE: &str = "domains.dns:update"; fn map_env_err(e: environments::EnvError) -> CliCoreError { CliCoreError::message(e.to_string()) @@ -71,7 +83,7 @@ fn authorization_header(provider: &str, token: &str) -> String { /// Build a Domains API client for the active environment, choosing the auth /// scheme from the resolved credential (sso-key for the bypass path, else /// Bearer). The credential is resolved through the registered composite provider. -async fn make_client(ctx: &CommandContext) -> Result { +pub(crate) async fn make_client(ctx: &CommandContext) -> Result { let env = ctx.middleware.env.clone(); let domains = environments::resolve_domains(&env).map_err(map_env_err)?; let cred = ctx.credential().await?; @@ -81,7 +93,7 @@ async fn make_client(ctx: &CommandContext) -> Result { .map_err(|e| CliCoreError::message(format!("failed to build domains client: {e}"))) } -fn string_list(ctx: &CommandContext, key: &str) -> Vec { +pub(crate) fn string_list(ctx: &CommandContext, key: &str) -> Vec { match ctx.args.get(key) { Some(serde_json::Value::Array(arr)) => arr .iter() @@ -92,17 +104,74 @@ fn string_list(ctx: &CommandContext, key: &str) -> Vec { } } +/// Validate `--status` values case-insensitively against the generated +/// `ListStatusesItem` enum (the API's `DomainStatus` set, e.g. `ACTIVE`), +/// returning the typed list the `list` builder expects. +fn parse_statuses(raw: &[String]) -> Result> { + raw.iter() + .map(|s| { + domains_client::types::ListStatusesItem::try_from(s.to_uppercase().as_str()) + .map_err(|_| CliCoreError::message(format!("invalid --status {s:?}"))) + }) + .collect() +} + pub fn module() -> Module { Module::new("Domains", |_ctx| { RuntimeGroupSpec::new(GroupSpec::new( "domain", - "Domain availability and suggestions", + "List your domains, check availability, and get suggestions", + )) + .with_command(RuntimeCommandSpec::new_with_context( + CommandSpec::new("list", "List the domains in your account") + .with_system("domain") + .with_tier(Tier::Read) + .with_default_fields("domain,status,expires,renewAuto") + .with_json_schema::() + .with_scopes(&[DOMAINS_READ_SCOPE]) + .with_arg( + clap::Arg::new("status") + .long("status") + .value_name("STATUS") + .action(clap::ArgAction::Append) + .help("Only domains with this status, e.g. ACTIVE (repeatable)"), + ), + |ctx| async move { + let statuses = parse_statuses(&string_list(&ctx, "status"))?; + + let client = make_client(&ctx).await?; + let mut req = client.list(); + if !statuses.is_empty() { + req = req.statuses(statuses); + } + let resp = req + .send() + .await + .map_err(|e| CliCoreError::message(format!("listing domains failed: {e}")))?; + + // Emit each summary as an object so `--fields`/default-field + // projection works (default shows domain/status/expires/renewAuto). + let domains: Vec = resp + .into_inner() + .iter() + .map(serde_json::to_value) + .collect::>() + .map_err(|e| { + CliCoreError::message(format!("failed to serialize domain list: {e}")) + })?; + + Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ + NextAction::new("dns list ", "View a domain's DNS records") + .with_param("domain", NextActionParam::required()), + ])) + }, )) .with_command(RuntimeCommandSpec::new_with_context( CommandSpec::new("available", "Check whether a domain is available") .with_system("domain") .with_tier(Tier::Read) .with_default_fields("domain,available,definitive,price,currency") + .with_json_schema::() .with_scopes(&[DOMAINS_READ_SCOPE]) .with_arg( clap::Arg::new("domain") @@ -198,6 +267,7 @@ pub fn module() -> Module { .with_system("domain") .with_tier(Tier::Read) .with_default_fields("domain") + .with_json_schema::() .with_scopes(&[DOMAINS_READ_SCOPE]) .with_arg( clap::Arg::new("query") @@ -291,7 +361,7 @@ pub fn module() -> Module { #[cfg(test)] mod tests { - use super::{authorization_header, format_price}; + use super::{authorization_header, format_price, parse_statuses}; use crate::auth::SSO_KEY_PROVIDER; use cli_engine::{Cli, CliConfig}; @@ -320,6 +390,22 @@ mod tests { assert_eq!(authorization_header("godaddy", "tok123"), "Bearer tok123"); } + #[test] + fn parse_statuses_is_case_insensitive_and_validates() { + use domains_client::types::ListStatusesItem; + let parsed = parse_statuses(&["active".to_string(), "CANCELLED".to_string()]) + .expect("valid statuses"); + assert_eq!( + parsed, + vec![ListStatusesItem::Active, ListStatusesItem::Cancelled] + ); + // Empty input is valid (no filter). + assert!(parse_statuses(&[]).expect("empty ok").is_empty()); + // Unknown status is rejected with a helpful message. + let err = parse_statuses(&["bogus".to_string()]).expect_err("should reject"); + assert!(err.to_string().contains("invalid --status"), "{err}"); + } + /// The `domain` commands call the Domains API, so they must stay fail-closed. /// Built with **no auth provider registered**, the engine's default /// `AuthRequirement::Required` must reject them at credential resolution @@ -331,8 +417,9 @@ mod tests { // No `--env` flag here: the global flag is registered in main.rs, not in // this minimal test harness, and env is irrelevant since auth resolution // fails before the handler runs. - for args in [ - [ + let cases: [&[&str]; 3] = [ + &["gddy", "domain", "list", "--output", "json"], + &[ "gddy", "domain", "available", @@ -340,14 +427,15 @@ mod tests { "--output", "json", ], - ["gddy", "domain", "suggest", "coffee", "--output", "json"], - ] { + &["gddy", "domain", "suggest", "coffee", "--output", "json"], + ]; + for args in cases { let cli = Cli::new( CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") .with_default_auth_provider("godaddy") .with_module(super::module()), ); - let output = cli.run(args).await; + let output = cli.run(args.iter().copied()).await; assert_eq!( output.exit_code, AUTH_FAILURE_EXIT, "{args:?} must fail closed at auth resolution, got: {}", diff --git a/rust/src/env/mod.rs b/rust/src/env/mod.rs index 182bc35..a04db09 100644 --- a/rust/src/env/mod.rs +++ b/rust/src/env/mod.rs @@ -4,6 +4,24 @@ use cli_engine::{ use serde_json::json; use crate::environments::{self, EnvError}; +use crate::output_schema::output_schema; + +output_schema!(EnvSummary { + "name": "string"; + "active": "bool"; + "apiUrl": "string"; +}); + +output_schema!(EnvActive { + "env": "string"; + "apiUrl": "string"; +}); + +output_schema!(EnvInfo { + "env": "string"; + "apiUrl": "string"; + "graphqlUrl": "string"; +}); /// Resolve the path to the `.gdenv` state file in the user's home directory. /// @@ -60,6 +78,7 @@ pub fn module() -> Module { CommandSpec::new("list", "List available environments") .with_system("env") .with_tier(Tier::Read) + .with_output_schema::() .no_auth(true), |_cred, _args| async move { let current = active_env(); @@ -89,6 +108,7 @@ pub fn module() -> Module { CommandSpec::new("get", "Get the active environment") .with_system("env") .with_tier(Tier::Read) + .with_output_schema::() .no_auth(true), |_cred, _args| async move { let env = active_env(); @@ -103,6 +123,7 @@ pub fn module() -> Module { CommandSpec::new("set", "Set the active environment") .with_system("env") .with_tier(Tier::Mutate) + .with_output_schema::() .no_auth(true) .with_arg( // Distinct id from the global `--env` flag (also id "env"); @@ -138,6 +159,7 @@ pub fn module() -> Module { CommandSpec::new("info", "Show details for the active environment") .with_system("env") .with_tier(Tier::Read) + .with_output_schema::() .no_auth(true), |_cred, _args| async move { let env = active_env(); diff --git a/rust/src/main.rs b/rust/src/main.rs index 8d3d8b8..e6660b6 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -3,10 +3,12 @@ mod api_explorer; mod application; mod auth; mod config; +mod dns; mod domain; mod env; mod environments; mod extension; +mod output_schema; mod payments; mod webhook; @@ -68,6 +70,7 @@ async fn main() -> ExitCode { .with_module(actions_catalog::module()) .with_module(api_explorer::module()) .with_module(application::module()) + .with_module(dns::module()) .with_module(domain::module()) .with_module(env::module()) .with_module(payments::module()) diff --git a/rust/src/output_schema.rs b/rust/src/output_schema.rs new file mode 100644 index 0000000..e378fef --- /dev/null +++ b/rust/src/output_schema.rs @@ -0,0 +1,57 @@ +//! Compact declaration of command output schemas. +//! +//! cli-engine surfaces a command's registered output schema in two places: the +//! `--help` text gets an "Output fields" section listing every field, and +//! `--schema` dumps the field metadata as JSON. Commands opt in by calling +//! [`CommandSpec::with_output_schema::()`](cli_engine::CommandSpec::with_output_schema) +//! with a type implementing [`cli_engine::OutputSchema`]. +//! +//! Hand-writing those impls for every command is noisy, so [`output_schema!`] +//! declares a zero-sized marker type and its `OutputSchema` impl from a terse +//! field list. The `field_type` strings are display-only (shown verbatim in +//! help), so they describe the JSON shape — `string`, `bool`, `number`, +//! `object`, `[]string`, `[]object`. +//! +//! These describe the shape each command's handler emits; keep them in sync with +//! the handler's `json!` output. They are informational (help/`--schema` only) +//! and never affect the actual command output. + +/// Declares a marker type and its [`cli_engine::OutputSchema`] impl. +/// +/// ```ignore +/// output_schema!(EnvSummary { +/// "name": "string"; +/// "active": "bool"; +/// "apiUrl": "string"; +/// }); +/// // ...later: +/// CommandSpec::new("list", "…").with_output_schema::() +/// ``` +/// +/// Append `, optional` to a field to mark it optional (renders `(optional)` in +/// help): +/// +/// ```ignore +/// output_schema!(AppSummary { "id": "string"; "description": "string", optional; }); +/// ``` +macro_rules! output_schema { + ($name:ident { $($field:literal : $ty:literal $(, $opt:ident)? );* $(;)? }) => { + pub(crate) struct $name; + + impl cli_engine::OutputSchema for $name { + fn fields() -> &'static [cli_engine::OutputField] { + &[ $( + cli_engine::OutputField { + name: $field, + field_type: $ty, + optional: output_schema!(@opt $($opt)?), + } + ),* ] + } + } + }; + (@opt optional) => { true }; + (@opt) => { false }; +} + +pub(crate) use output_schema;