Skip to content

Commit b55f932

Browse files
committed
Read API version and endpoint paths from settings
1 parent 952b9a0 commit b55f932

7 files changed

Lines changed: 118 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ First release.
1111
- Typed errors, with automatic retries for rate limits and server errors
1212
- Configuration through goodconf: code, `GODADDY_*` environment variables or a TOML, YAML or JSON file
1313
- Every user facing message lives in `messages.yaml` and can be overridden at runtime
14+
- API version and every endpoint path come from the settings and can be overridden

‎README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ pip install "godaddy-python-sdk[yaml] @ git+https://github.com/qiyascc/godaddy-p
5555
| `base_url` | `GODADDY_BASE_URL` | taken from `environment` |
5656
| `production_url` | `GODADDY_PRODUCTION_URL` | `https://api.godaddy.com` |
5757
| `ote_url` | `GODADDY_OTE_URL` | `https://api.ote-godaddy.com` |
58+
| `api_version` | `GODADDY_API_VERSION` | `v1` |
59+
| `endpoints.*` | `GODADDY_ENDPOINTS__<NAME>` | see below |
5860
| `shopper_id` | `GODADDY_SHOPPER_ID` | |
5961
| `timeout` | `GODADDY_TIMEOUT` | `30` |
6062
| `max_retries` | `GODADDY_MAX_RETRIES` | `3` |
@@ -73,6 +75,34 @@ settings = load_settings("/etc/myapp/godaddy.toml", environment="ote")
7375
gd = GoDaddy(settings=settings)
7476
```
7577

78+
### Endpoints
79+
80+
API paths are part of the settings too, so nothing is pinned in the code. Each one is a template relative to the base URL. `{version}` comes from `api_version`, and the other placeholders are filled in and URL encoded for each call.
81+
82+
| Name | Default |
83+
| --- | --- |
84+
| `domains` | `/{version}/domains` |
85+
| `domain` | `/{version}/domains/{domain}` |
86+
| `available` | `/{version}/domains/available` |
87+
| `suggest` | `/{version}/domains/suggest` |
88+
| `tlds` | `/{version}/domains/tlds` |
89+
| `records` | `/{version}/domains/{domain}/records` |
90+
| `records_by_type` | `/{version}/domains/{domain}/records/{type}` |
91+
| `record` | `/{version}/domains/{domain}/records/{type}/{name}` |
92+
93+
You can override one of them in a config file:
94+
95+
```toml
96+
[endpoints]
97+
records = "/{version}/domains/{domain}/records"
98+
```
99+
100+
or through the environment, with a double underscore between the parts:
101+
102+
```bash
103+
export GODADDY_ENDPOINTS__TLDS="/{version}/domains/tlds"
104+
```
105+
76106
GoDaddy gives you two environments. `production` is the default. `ote` is their test sandbox, and it needs its own keys.
77107

78108
Heads up: GoDaddy only opens the production Domains API to some account types. If every call comes back with `PermissionDeniedError`, check your account's API access first.

‎src/godaddy/config.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
11
from __future__ import annotations
22

33
from typing import Any, Optional
4+
from urllib.parse import quote
45

56
from goodconf import Field, GoodConf, GoodConfConfigDict
6-
from pydantic import SecretStr
7+
from pydantic import BaseModel, SecretStr
78

89
from ._version import __version__
910
from .errors import AuthenticationError
1011
from .messages import text
1112

1213

14+
class Endpoints(BaseModel):
15+
domains: str = "/{version}/domains"
16+
domain: str = "/{version}/domains/{domain}"
17+
available: str = "/{version}/domains/available"
18+
suggest: str = "/{version}/domains/suggest"
19+
tlds: str = "/{version}/domains/tlds"
20+
records: str = "/{version}/domains/{domain}/records"
21+
records_by_type: str = "/{version}/domains/{domain}/records/{type}"
22+
record: str = "/{version}/domains/{domain}/records/{type}/{name}"
23+
24+
1325
class Settings(GoodConf):
1426
model_config = GoodConfConfigDict(
1527
env_prefix="GODADDY_",
1628
file_env_var="GODADDY_CONFIG",
1729
default_files=["godaddy.toml", "godaddy.yaml", "godaddy.yml", "godaddy.json"],
1830
extra="ignore",
31+
env_nested_delimiter="__",
1932
)
2033

2134
api_key: SecretStr = Field(default=SecretStr(""), description=text("settings.api_key"))
@@ -24,6 +37,8 @@ class Settings(GoodConf):
2437
base_url: Optional[str] = Field(default=None, description=text("settings.base_url"))
2538
production_url: str = Field(default="https://api.godaddy.com", description=text("settings.production_url"))
2639
ote_url: str = Field(default="https://api.ote-godaddy.com", description=text("settings.ote_url"))
40+
api_version: str = Field(default="v1", description=text("settings.api_version"))
41+
endpoints: Endpoints = Field(default_factory=Endpoints, description=text("settings.endpoints"))
2742
shopper_id: Optional[str] = Field(default=None, description=text("settings.shopper_id"))
2843
timeout: float = Field(default=30.0, gt=0, description=text("settings.timeout"))
2944
max_retries: int = Field(default=3, ge=0, description=text("settings.max_retries"))
@@ -51,6 +66,11 @@ def resolved_base_url(self) -> str:
5166
raise ValueError(text("error.unknown_environment", environment=env, choices=choices))
5267
return self.environments[env].rstrip("/")
5368

69+
def path(self, endpoint: str, **params: str) -> str:
70+
template = getattr(self.endpoints, endpoint)
71+
values = {key: quote(str(value), safe="@") for key, value in params.items()}
72+
return template.format(version=quote(self.api_version, safe=""), **values)
73+
5474
def resolved_user_agent(self) -> str:
5575
return self.user_agent.replace("{version}", __version__)
5676

‎src/godaddy/messages.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ settings:
66
base_url: Full API URL. Overrides the environment when set.
77
production_url: API URL used for the production environment.
88
ote_url: API URL used for the OTE test environment.
9+
api_version: API version that fills the {version} part of every endpoint.
10+
endpoints: Path templates for every API call, relative to the base URL.
911
shopper_id: Customer shopper id for reseller calls, sent as X-Shopper-Id.
1012
timeout: Seconds to wait for a single request.
1113
max_retries: How many times a failed request is tried again.

‎src/godaddy/resources/dns.py‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import ipaddress
44
from typing import Any, Iterable, Optional, Sequence, Union
5-
from urllib.parse import quote
65

76
from .._base import Call
87
from ..messages import text
@@ -15,14 +14,14 @@ def _domain(domain: str) -> str:
1514
value = domain.strip().lower().rstrip(".")
1615
if not value:
1716
raise ValueError(text("value.empty_domain"))
18-
return quote(value, safe="")
17+
return value
1918

2019

2120
def _name(name: str) -> str:
2221
value = name.strip()
2322
if not value:
2423
raise ValueError(text("value.empty_name"))
25-
return quote(value, safe="@")
24+
return value
2625

2726

2827
def _records(payload: Any) -> list[DNSRecord]:
@@ -55,6 +54,9 @@ def __init__(self, client: Any) -> None:
5554
def _run(self, call: Call) -> Any:
5655
return self._client._execute(call)
5756

57+
def _path(self, endpoint: str, **params: str) -> str:
58+
return self._client.settings.path(endpoint, **params)
59+
5860
def list(
5961
self,
6062
domain: str,
@@ -64,15 +66,17 @@ def list(
6466
offset: Optional[int] = None,
6567
limit: Optional[int] = None,
6668
) -> Any:
67-
path = f"/v1/domains/{_domain(domain)}/records"
6869
wanted_name = name.strip() if name else None
6970

7071
if type is not None:
71-
path += f"/{quote(record_type(type), safe='')}"
7272
if wanted_name:
73-
path += f"/{_name(wanted_name)}"
73+
path = self._path("record", domain=_domain(domain), type=record_type(type), name=_name(wanted_name))
74+
else:
75+
path = self._path("records_by_type", domain=_domain(domain), type=record_type(type))
7476
parse = _records
7577
else:
78+
path = self._path("records", domain=_domain(domain))
79+
7680
def parse(payload: Any) -> list[DNSRecord]:
7781
records = _records(payload)
7882
if wanted_name:
@@ -82,18 +86,18 @@ def parse(payload: Any) -> list[DNSRecord]:
8286
return self._run(Call("GET", path, params={"offset": offset, "limit": limit}, parse=parse))
8387

8488
def get(self, domain: str, type: RecordTypeLike, name: str) -> Any:
85-
path = f"/v1/domains/{_domain(domain)}/records/{quote(record_type(type), safe='')}/{_name(name)}"
89+
path = self._path("record", domain=_domain(domain), type=record_type(type), name=_name(name))
8690
return self._run(Call("GET", path, parse=_records))
8791

8892
def exists(self, domain: str, type: RecordTypeLike, name: str) -> Any:
89-
path = f"/v1/domains/{_domain(domain)}/records/{quote(record_type(type), safe='')}/{_name(name)}"
93+
path = self._path("record", domain=_domain(domain), type=record_type(type), name=_name(name))
9094
return self._run(Call("GET", path, parse=lambda payload: bool(_records(payload))))
9195

9296
def add(self, domain: str, *records: DNSRecord) -> Any:
9397
if not records:
9498
raise ValueError(text("value.add_needs_records"))
9599
body = [record.to_api() for record in records]
96-
return self._run(Call("PATCH", f"/v1/domains/{_domain(domain)}/records", json=body, parse=lambda _: None))
100+
return self._run(Call("PATCH", self._path("records", domain=_domain(domain)), json=body, parse=lambda _: None))
97101

98102
def replace(
99103
self,
@@ -104,7 +108,7 @@ def replace(
104108
*,
105109
ttl: int = 600,
106110
) -> Any:
107-
path = f"/v1/domains/{_domain(domain)}/records/{quote(record_type(type), safe='')}/{_name(name)}"
111+
path = self._path("record", domain=_domain(domain), type=record_type(type), name=_name(name))
108112
body = _record_bodies(values, ttl)
109113
return self._run(Call("PUT", path, json=body, parse=lambda _: None))
110114

@@ -114,15 +118,15 @@ def replace_type(self, domain: str, type: RecordTypeLike, records: Sequence[DNSR
114118
if record.type != kind:
115119
raise ValueError(text("value.type_mismatch", name=record.name, actual=record.type, expected=kind))
116120
body = [{"name": r.name, **r.to_api(include_identity=False)} for r in records]
117-
path = f"/v1/domains/{_domain(domain)}/records/{quote(kind, safe='')}"
121+
path = self._path("records_by_type", domain=_domain(domain), type=kind)
118122
return self._run(Call("PUT", path, json=body, parse=lambda _: None))
119123

120124
def replace_all(self, domain: str, records: Sequence[DNSRecord]) -> Any:
121125
body = [record.to_api() for record in records]
122-
return self._run(Call("PUT", f"/v1/domains/{_domain(domain)}/records", json=body, parse=lambda _: None))
126+
return self._run(Call("PUT", self._path("records", domain=_domain(domain)), json=body, parse=lambda _: None))
123127

124128
def delete(self, domain: str, type: RecordTypeLike, name: str, *, missing_ok: bool = False) -> Any:
125-
path = f"/v1/domains/{_domain(domain)}/records/{quote(record_type(type), safe='')}/{_name(name)}"
129+
path = self._path("record", domain=_domain(domain), type=record_type(type), name=_name(name))
126130
allow = frozenset({404}) if missing_ok else frozenset()
127131
return self._run(Call("DELETE", path, parse=lambda _: None, allow_status=allow))
128132

‎src/godaddy/resources/domains.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def __init__(self, client: Any) -> None:
2626
def _run(self, call: Call) -> Any:
2727
return self._client._execute(call)
2828

29+
def _path(self, endpoint: str, **params: str) -> str:
30+
return self._client.settings.path(endpoint, **params)
31+
2932
def _list_call(
3033
self,
3134
statuses: Optional[Iterable[str]],
@@ -41,7 +44,7 @@ def _list_call(
4144
"marker": marker,
4245
"includes": list(includes) if includes else None,
4346
}
44-
return Call("GET", "/v1/domains", params=params, parse=_domains)
47+
return Call("GET", self._path("domains"), params=params, parse=_domains)
4548

4649
def list(
4750
self,
@@ -55,22 +58,22 @@ def list(
5558
return self._run(self._list_call(statuses, status_groups, limit, marker, includes))
5659

5760
def get(self, domain: str) -> Any:
58-
return self._run(Call("GET", f"/v1/domains/{_domain(domain)}", parse=Domain.from_api))
61+
return self._run(Call("GET", self._path("domain", domain=_domain(domain)), parse=Domain.from_api))
5962

6063
def available(self, domain: str, *, full_check: bool = False, for_transfer: bool = False) -> Any:
6164
params = {
6265
"domain": domain.strip().lower(),
6366
"checkType": "FULL" if full_check else "FAST",
6467
"forTransfer": for_transfer,
6568
}
66-
return self._run(Call("GET", "/v1/domains/available", params=params, parse=Availability.from_api))
69+
return self._run(Call("GET", self._path("available"), params=params, parse=Availability.from_api))
6770

6871
def available_many(self, domains: Iterable[str], *, full_check: bool = False) -> Any:
6972
body = [d.strip().lower() for d in domains]
7073
if not body:
7174
raise ValueError(text("value.no_domains"))
7275
params = {"checkType": "FULL" if full_check else "FAST"}
73-
return self._run(Call("POST", "/v1/domains/available", params=params, json=body, parse=_bulk_availability))
76+
return self._run(Call("POST", self._path("available"), params=params, json=body, parse=_bulk_availability))
7477

7578
def suggest(
7679
self,
@@ -95,11 +98,11 @@ def suggest(
9598
"waitMs": wait_ms,
9699
}
97100
parse = lambda payload: [Suggestion.from_api(item) for item in payload or []]
98-
return self._run(Call("GET", "/v1/domains/suggest", params=params, parse=parse))
101+
return self._run(Call("GET", self._path("suggest"), params=params, parse=parse))
99102

100103
def tlds(self) -> Any:
101104
parse = lambda payload: [Tld.from_api(item) for item in payload or []]
102-
return self._run(Call("GET", "/v1/domains/tlds", parse=parse))
105+
return self._run(Call("GET", self._path("tlds"), parse=parse))
103106

104107
def update(
105108
self,
@@ -121,7 +124,7 @@ def update(
121124
body["subaccountId"] = subaccount_id
122125
if not body:
123126
raise ValueError(text("value.update_needs_fields"))
124-
return self._run(Call("PATCH", f"/v1/domains/{_domain(domain)}", json=body, parse=lambda _: None))
127+
return self._run(Call("PATCH", self._path("domain", domain=_domain(domain)), json=body, parse=lambda _: None))
125128

126129

127130
class Domains(_DomainsBase):

‎tests/test_client.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,40 @@ def test_headers_sent(gd, api):
9999
assert headers["authorization"] == "sso-key key:secret"
100100
assert headers["accept"] == "application/json"
101101
assert headers["user-agent"].startswith("godaddy-python-sdk/")
102+
103+
104+
def test_endpoints_from_settings(api):
105+
import httpx
106+
107+
http = httpx.Client(transport=httpx.MockTransport(api))
108+
client = GoDaddy("k", "s", http_client=http, api_version="v2")
109+
api.queue(200, [])
110+
client.dns.list("example.com", "A", "www")
111+
assert api.last.url.path == "/v2/domains/example.com/records/A/www"
112+
113+
114+
def test_endpoint_override_from_env(monkeypatch, api):
115+
import httpx
116+
117+
monkeypatch.setenv("GODADDY_ENDPOINTS__RECORDS", "/custom/{version}/zones/{domain}")
118+
http = httpx.Client(transport=httpx.MockTransport(api))
119+
client = GoDaddy("k", "s", http_client=http)
120+
api.queue(200, [])
121+
client.dns.list("example.com")
122+
assert api.last.url.path == "/custom/v1/zones/example.com"
123+
124+
125+
def test_endpoint_override_from_file(monkeypatch, tmp_path):
126+
(tmp_path / "godaddy.toml").write_text(
127+
'api_key = "k"\napi_secret = "s"\n\n[endpoints]\ntlds = "/{version}/catalog/tlds"\n'
128+
)
129+
settings = load_settings()
130+
assert settings.path("tlds") == "/v1/catalog/tlds"
131+
assert settings.path("domains") == "/v1/domains"
132+
133+
134+
def test_path_values_are_quoted():
135+
settings = load_settings(api_key="k", api_secret="s")
136+
path = settings.path("record", domain="example.com", type="TXT", name="a b/c")
137+
assert path == "/v1/domains/example.com/records/TXT/a%20b%2Fc"
138+
assert settings.path("record", domain="example.com", type="A", name="@").endswith("/A/@")

0 commit comments

Comments
 (0)