From bb21edd9eb32893e8477a650a77b99ef8a1be5b9 Mon Sep 17 00:00:00 2001 From: Michael Jerris Date: Wed, 22 Jul 2026 09:11:04 -0400 Subject: [PATCH] feat(rest): generate fabric address-management resources (client.fabric.*) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (python-first) of the fabric address-management REST rollout. All generated files were produced by porting-sdk's generate_python_rest_types.py against the reshaped rest-apis/fabric-addresses/openapi.yaml — NOT hand-written (they carry the AUTO-GENERATED ... DO NOT EDIT header). Three full-CRUD resources fold into the existing client.fabric container (the spec declares x-sdk-namespace.attr: fabric), siblings of the read-only client.fabric.addresses: client.fabric.sip_addresses -> /api/fabric/sip_addresses client.fabric.phone_number_addresses -> /api/fabric/phone_number_addresses client.fabric.alias_addresses -> /api/fabric/alias_addresses Each has list/get/delete (from CrudResource) + closed-typed create/update (PATCH), bound to the spec's typed request/response schemas. Generated: - fabric_addresses_resources_generated.py — SipAddresses / PhoneNumberAddresses / AliasAddresses typed CRUD subclasses. - fabric_addresses_types_generated.py — 47 TypedDicts (bodies, lists, 422s, shared status-code schemas). - _client_tree_generated.py — the three new accessors wired under FabricNamespace. - tests/unit/rest/fabric_generated_test.py — the generator's wire-test suite, now covering the three new resources (CRUD success + a 500 error path each). Hand-written (over the shared mock, no requests mock.patch): - tests/unit/rest/test_fabric_addresses_mock.py — 21 tests: every CRUD op asserts (method, path, matched_route) on the wire, plus a structured 401 Unauthorized and 404 Not Found error path per resource. Requires the coordinated porting-sdk feat/fabric-addresses PR (the spec + SPEC_NAMES + regenerated oracle). The 9-port fan-out is DEFERRED to a later owner-gated phase (cross-port red-until-implemented by design). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NqhUoqrbptHNS3cypq9s6t --- .../rest/namespaces/_client_tree_generated.py | 8 + .../fabric_addresses_resources_generated.py | 283 +++++++++++++++ .../fabric_addresses_types_generated.py | 339 ++++++++++++++++++ tests/unit/rest/fabric_generated_test.py | 258 +++++++++++++ tests/unit/rest/test_fabric_addresses_mock.py | 223 ++++++++++++ 5 files changed, 1111 insertions(+) create mode 100644 signalwire/signalwire/rest/namespaces/fabric_addresses_resources_generated.py create mode 100644 signalwire/signalwire/rest/namespaces/fabric_addresses_types_generated.py create mode 100644 tests/unit/rest/test_fabric_addresses_mock.py diff --git a/signalwire/signalwire/rest/namespaces/_client_tree_generated.py b/signalwire/signalwire/rest/namespaces/_client_tree_generated.py index 6091feec..7e3ca6dc 100644 --- a/signalwire/signalwire/rest/namespaces/_client_tree_generated.py +++ b/signalwire/signalwire/rest/namespaces/_client_tree_generated.py @@ -16,6 +16,11 @@ from .datasphere_resources_generated import ( DatasphereDocuments, ) +from .fabric_addresses_resources_generated import ( + AliasAddresses, + PhoneNumberAddresses, + SipAddresses, +) from .fabric_resources_generated import ( AiAgents, CallFlows, @@ -99,14 +104,17 @@ class FabricNamespace: def __init__(self, http: Any) -> None: self.addresses = FabricAddresses(http) self.ai_agents = AiAgents(http) + self.alias_addresses = AliasAddresses(http) self.call_flows = CallFlows(http) self.conference_rooms = ConferenceRooms(http) self.cxml_applications = CxmlApplications(http) self.cxml_scripts = CxmlScripts(http) self.cxml_webhooks = CxmlWebhooks(http) self.freeswitch_connectors = FreeswitchConnectors(http) + self.phone_number_addresses = PhoneNumberAddresses(http) self.relay_applications = RelayApplications(http) self.resources = GenericResources(http) + self.sip_addresses = SipAddresses(http) self.sip_endpoints = SipEndpoints(http) self.sip_gateways = SipGateways(http) self.subscribers = Subscribers(http) diff --git a/signalwire/signalwire/rest/namespaces/fabric_addresses_resources_generated.py b/signalwire/signalwire/rest/namespaces/fabric_addresses_resources_generated.py new file mode 100644 index 00000000..8b008f2b --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/fabric_addresses_resources_generated.py @@ -0,0 +1,283 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/fabric-addresses/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**_reserved_kw`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast +from collections.abc import Mapping + +from .._base import CrudResource + +if TYPE_CHECKING: + from .._request_options import RequestOptions + + from .fabric_addresses_types_generated import ( + AddressContext, + AliasAddress, + AliasAddressCreate, + AliasAddressList, + AliasAddressUpdate, + Channel, + Codec, + HandlerType, + PhoneNumberAddress, + PhoneNumberAddressCreate, + PhoneNumberAddressList, + PhoneNumberAddressUpdate, + SipAddress, + SipAddressCreate, + SipAddressList, + SipAddressUpdate, + SipEncryption, + SrtpCipher, + ) + + +class SipAddresses( + CrudResource["SipAddressList", "SipAddress", "SipAddressCreate", "SipAddressUpdate"] +): + """Typed resource for ``/sip_addresses`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/sip_addresses") + + def create( # type: ignore[override] + self, + *, + name: str, + calling_handler_resource_id: str, + user: str | None = None, + context_id: str | None = None, + ip_auth_enabled: bool | None = None, + ip_auth: list[str] | None = None, + codecs: list[Codec] | None = None, + ciphers: list[SrtpCipher] | None = None, + encryption: SipEncryption | None = None, + password: str | None = None, + extras: Mapping[str, Any] | None = None, + request_options: RequestOptions | None = None, + **_reserved_kw: Any, + ) -> SipAddress: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "user": user, + "context_id": context_id, + "calling_handler_resource_id": calling_handler_resource_id, + "ip_auth_enabled": ip_auth_enabled, + "ip_auth": ip_auth, + "codecs": codecs, + "ciphers": ciphers, + "encryption": encryption, + "password": password, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(_reserved_kw) + return cast( + "SipAddress", + self._http.post( + self._base_path, body=body, request_options=request_options + ), + ) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + user: str | None = None, + context_id: str | None = None, + ip_auth_enabled: bool | None = None, + ip_auth: list[str] | None = None, + codecs: list[Codec] | None = None, + ciphers: list[SrtpCipher] | None = None, + encryption: SipEncryption | None = None, + password: str | None = None, + extras: Mapping[str, Any] | None = None, + request_options: RequestOptions | None = None, + **_reserved_kw: Any, + ) -> SipAddress: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "user": user, + "context_id": context_id, + "ip_auth_enabled": ip_auth_enabled, + "ip_auth": ip_auth, + "codecs": codecs, + "ciphers": ciphers, + "encryption": encryption, + "password": password, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(_reserved_kw) + return cast( + "SipAddress", + self._http.patch( + self._path(id), body=body, request_options=request_options + ), + ) + + +class PhoneNumberAddresses( + CrudResource[ + "PhoneNumberAddressList", + "PhoneNumberAddress", + "PhoneNumberAddressCreate", + "PhoneNumberAddressUpdate", + ] +): + """Typed resource for ``/phone_number_addresses`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/phone_number_addresses") + + def create( # type: ignore[override] + self, + *, + resource_id: str, + handler_type: HandlerType, + phone_number_id: str | None = None, + number: str | None = None, + extras: Mapping[str, Any] | None = None, + request_options: RequestOptions | None = None, + **_reserved_kw: Any, + ) -> PhoneNumberAddress: + body: dict[str, Any] = { + k: v + for k, v in { + "phone_number_id": phone_number_id, + "number": number, + "resource_id": resource_id, + "handler_type": handler_type, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(_reserved_kw) + return cast( + "PhoneNumberAddress", + self._http.post( + self._base_path, body=body, request_options=request_options + ), + ) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + resource_id: str | None = None, + extras: Mapping[str, Any] | None = None, + request_options: RequestOptions | None = None, + **_reserved_kw: Any, + ) -> PhoneNumberAddress: + body: dict[str, Any] = { + k: v + for k, v in {"name": name, "resource_id": resource_id}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(_reserved_kw) + return cast( + "PhoneNumberAddress", + self._http.patch( + self._path(id), body=body, request_options=request_options + ), + ) + + +class AliasAddresses( + CrudResource[ + "AliasAddressList", "AliasAddress", "AliasAddressCreate", "AliasAddressUpdate" + ] +): + """Typed resource for ``/alias_addresses`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/alias_addresses") + + def create( # type: ignore[override] + self, + *, + name: str, + resource_id: str, + display_name: str | None = None, + channels: list[Channel] | None = None, + codecs: list[Codec] | None = None, + context: AddressContext | None = None, + extras: Mapping[str, Any] | None = None, + request_options: RequestOptions | None = None, + **_reserved_kw: Any, + ) -> AliasAddress: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "resource_id": resource_id, + "display_name": display_name, + "channels": channels, + "codecs": codecs, + "context": context, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(_reserved_kw) + return cast( + "AliasAddress", + self._http.post( + self._base_path, body=body, request_options=request_options + ), + ) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + display_name: str | None = None, + channels: list[Channel] | None = None, + codecs: list[Codec] | None = None, + context: AddressContext | None = None, + extras: Mapping[str, Any] | None = None, + request_options: RequestOptions | None = None, + **_reserved_kw: Any, + ) -> AliasAddress: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "display_name": display_name, + "channels": channels, + "codecs": codecs, + "context": context, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(_reserved_kw) + return cast( + "AliasAddress", + self._http.patch( + self._path(id), body=body, request_options=request_options + ), + ) diff --git a/signalwire/signalwire/rest/namespaces/fabric_addresses_types_generated.py b/signalwire/signalwire/rest/namespaces/fabric_addresses_types_generated.py new file mode 100644 index 00000000..4b89798b --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/fabric_addresses_types_generated.py @@ -0,0 +1,339 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/fabric-addresses/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Literal, TypeAlias, TypedDict + +Codec: TypeAlias = "Literal['OPUS', 'G722', 'PCMU', 'PCMA', 'G729', 'VP8', 'H264']" + +SrtpCipher: TypeAlias = "Literal['AEAD_AES_256_GCM_8', 'AES_256_CM_HMAC_SHA1_80', 'AES_CM_128_HMAC_SHA1_80', 'AES_256_CM_HMAC_SHA1_32', 'AES_CM_128_HMAC_SHA1_32']" + +SipEncryption: TypeAlias = "Literal['required', 'optional', 'forbidden']" + +HandlerType: TypeAlias = "Literal['calling', 'messaging']" + +Channel: TypeAlias = "Literal['audio', 'messaging', 'video']" + +AddressContext: TypeAlias = "Literal['public', 'private']" + +DisplayType: TypeAlias = "Literal['app', 'room', 'call', 'subscriber']" + + +class PaginationLinks(TypedDict, total=False): + """Cursor-pagination links for a page of results. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + self: str + first: str + next: str + prev: str + + +class SipAddress(TypedDict, total=False): + """A Call Fabric SIP address and its SIP configuration. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + type: Literal["sip_address"] + resource_id: str + name: str + display_name: str + context: str + uri: str + user: str + encryption: SipEncryption + codecs: list[Codec] + ciphers: list[SrtpCipher] + ip_auth_enabled: bool + ip_auth: list[str] + calling_handler_resource_id: str + created_at: str + updated_at: str + + +class SipAddressCreate(TypedDict, total=False): + """Request body for creating a SIP address. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + user: str + context_id: str + calling_handler_resource_id: str + ip_auth_enabled: bool + ip_auth: list[str] + codecs: list[Codec] + ciphers: list[SrtpCipher] + encryption: SipEncryption + password: str + + +class SipAddressUpdate(TypedDict, total=False): + """Partial update; omitted fields keep their current value. `calling_handler_resource_id` is not editable. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + user: str + context_id: str + ip_auth_enabled: bool + ip_auth: list[str] + codecs: list[Codec] + ciphers: list[SrtpCipher] + encryption: SipEncryption + password: str + + +class SipAddressList(TypedDict, total=False): + """A page of SIP addresses. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + items_count: int + data: list[SipAddress] + + +class SipAddressCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SipAddressUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class PhoneNumberAddress(TypedDict, total=False): + """A Call Fabric address backed by an owned phone number, representing one channel (calling or messaging) routing to a handler resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + type: Literal["phone_number_address"] + handler_type: HandlerType + resource_id: str | None + name: str + phone_number: str + phone_number_id: str + created_at: str + updated_at: str + + +class PhoneNumberAddressCreate(TypedDict, total=False): + """Reference the owned number by `phone_number_id` or `number` (at least one is required). + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + phone_number_id: str + number: str + resource_id: str + handler_type: HandlerType + + +class PhoneNumberAddressUpdate(TypedDict, total=False): + """Partial update; omitted fields keep their current value. `handler_type` is not editable. Provide `name` (rename) and/or `resource_id` (re-point). + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + resource_id: str + + +class PhoneNumberAddressList(TypedDict, total=False): + """A page of phone number addresses. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + items_count: int + data: list[PhoneNumberAddress] + + +class PhoneNumberAddressCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class PhoneNumberAddressUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class AliasAddress(TypedDict, total=False): + """A named, context-scoped alias route that forwards to a handler resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + type: Literal["alias_address"] + resource_id: str + name: str + display_name: str + display_type: DisplayType + channels: list[Channel] + codecs: list[Codec] + context: AddressContext + uri: str + created_at: str + updated_at: str + + +class AliasAddressCreate(TypedDict, total=False): + """Request body for creating an alias address. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + resource_id: str + display_name: str + channels: list[Channel] + codecs: list[Codec] + context: AddressContext + + +class AliasAddressUpdate(TypedDict, total=False): + """Partial update; omitted fields keep their current value. `resource_id` is not accepted (the handler is create-only). + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + display_name: str + channels: list[Channel] + codecs: list[Codec] + context: AddressContext + + +class AliasAddressList(TypedDict, total=False): + """A page of alias addresses. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + items_count: int + data: list[AliasAddress] + + +class AliasAddressCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class AliasAddressUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized (missing/invalid credentials, a token without a Call Fabric scope, or a subdomain that does not match the token's project). + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +ListSipAddressesResponse: TypeAlias = "SipAddressList" +CreateSipAddressRequest: TypeAlias = "SipAddressCreate" +CreateSipAddressResponse: TypeAlias = "SipAddress" +GetSipAddressResponse: TypeAlias = "SipAddress" +UpdateSipAddressRequest: TypeAlias = "SipAddressUpdate" +UpdateSipAddressResponse: TypeAlias = "SipAddress" +ListPhoneNumberAddressesResponse: TypeAlias = "PhoneNumberAddressList" +CreatePhoneNumberAddressRequest: TypeAlias = "PhoneNumberAddressCreate" +CreatePhoneNumberAddressResponse: TypeAlias = "PhoneNumberAddress" +GetPhoneNumberAddressResponse: TypeAlias = "PhoneNumberAddress" +UpdatePhoneNumberAddressRequest: TypeAlias = "PhoneNumberAddressUpdate" +UpdatePhoneNumberAddressResponse: TypeAlias = "PhoneNumberAddress" +ListAliasAddressesResponse: TypeAlias = "AliasAddressList" +CreateAliasAddressRequest: TypeAlias = "AliasAddressCreate" +CreateAliasAddressResponse: TypeAlias = "AliasAddress" +GetAliasAddressResponse: TypeAlias = "AliasAddress" +UpdateAliasAddressRequest: TypeAlias = "AliasAddressUpdate" +UpdateAliasAddressResponse: TypeAlias = "AliasAddress" diff --git a/tests/unit/rest/fabric_generated_test.py b/tests/unit/rest/fabric_generated_test.py index c0dcc9b1..bcf5510e 100644 --- a/tests/unit/rest/fabric_generated_test.py +++ b/tests/unit/rest/fabric_generated_test.py @@ -151,6 +151,86 @@ def test_ai_agents_update_error( signalwire_client.fabric.ai_agents.update("test-id") assert exc.value.status_code == 500 + def test_alias_addresses_create( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.alias_addresses.create(name="x", resource_id="x") + last = mock.last_request() + assert last.method == "POST" + assert last.matched_route == "fabric-addresses.create_alias_address" + + def test_alias_addresses_create_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.create_alias_address", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.alias_addresses.create(name="x", resource_id="x") + assert exc.value.status_code == 500 + + def test_alias_addresses_delete( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.alias_addresses.delete("test-id") + last = mock.last_request() + assert last.method == "DELETE" + assert last.matched_route == "fabric-addresses.delete_alias_address" + + def test_alias_addresses_delete_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.delete_alias_address", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.alias_addresses.delete("test-id") + assert exc.value.status_code == 500 + + def test_alias_addresses_get( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.alias_addresses.get("test-id") + last = mock.last_request() + assert last.method == "GET" + assert last.matched_route == "fabric-addresses.get_alias_address" + + def test_alias_addresses_get_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.get_alias_address", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.alias_addresses.get("test-id") + assert exc.value.status_code == 500 + + def test_alias_addresses_list( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.alias_addresses.list() + last = mock.last_request() + assert last.method == "GET" + assert last.matched_route == "fabric-addresses.list_alias_addresses" + + def test_alias_addresses_list_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.list_alias_addresses", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.alias_addresses.list() + assert exc.value.status_code == 500 + + def test_alias_addresses_update( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.alias_addresses.update("test-id") + last = mock.last_request() + assert last.method == "PATCH" + assert last.matched_route == "fabric-addresses.update_alias_address" + + def test_alias_addresses_update_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.update_alias_address", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.alias_addresses.update("test-id") + assert exc.value.status_code == 500 + def test_call_flows_create( self, signalwire_client: RestClient, mock: _MockHarness ) -> None: @@ -751,6 +831,100 @@ def test_freeswitch_connectors_update_error( signalwire_client.fabric.freeswitch_connectors.update("test-id") assert exc.value.status_code == 500 + def test_phone_number_addresses_create( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.phone_number_addresses.create( + resource_id="x", handler_type="calling" + ) + last = mock.last_request() + assert last.method == "POST" + assert last.matched_route == "fabric-addresses.create_phone_number_address" + + def test_phone_number_addresses_create_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.create_phone_number_address", 500, {"error": "x"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.phone_number_addresses.create( + resource_id="x", handler_type="calling" + ) + assert exc.value.status_code == 500 + + def test_phone_number_addresses_delete( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.phone_number_addresses.delete("test-id") + last = mock.last_request() + assert last.method == "DELETE" + assert last.matched_route == "fabric-addresses.delete_phone_number_address" + + def test_phone_number_addresses_delete_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.delete_phone_number_address", 500, {"error": "x"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.phone_number_addresses.delete("test-id") + assert exc.value.status_code == 500 + + def test_phone_number_addresses_get( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.phone_number_addresses.get("test-id") + last = mock.last_request() + assert last.method == "GET" + assert last.matched_route == "fabric-addresses.get_phone_number_address" + + def test_phone_number_addresses_get_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.get_phone_number_address", 500, {"error": "x"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.phone_number_addresses.get("test-id") + assert exc.value.status_code == 500 + + def test_phone_number_addresses_list( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.phone_number_addresses.list() + last = mock.last_request() + assert last.method == "GET" + assert last.matched_route == "fabric-addresses.list_phone_number_addresses" + + def test_phone_number_addresses_list_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.list_phone_number_addresses", 500, {"error": "x"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.phone_number_addresses.list() + assert exc.value.status_code == 500 + + def test_phone_number_addresses_update( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.phone_number_addresses.update("test-id") + last = mock.last_request() + assert last.method == "PATCH" + assert last.matched_route == "fabric-addresses.update_phone_number_address" + + def test_phone_number_addresses_update_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.update_phone_number_address", 500, {"error": "x"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.phone_number_addresses.update("test-id") + assert exc.value.status_code == 500 + def test_relay_applications_create( self, signalwire_client: RestClient, mock: _MockHarness ) -> None: @@ -955,6 +1129,90 @@ def test_resources_list_addresses_error( signalwire_client.fabric.resources.list_addresses("test-id") assert exc.value.status_code == 500 + def test_sip_addresses_create( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.sip_addresses.create( + name="x", calling_handler_resource_id="x" + ) + last = mock.last_request() + assert last.method == "POST" + assert last.matched_route == "fabric-addresses.create_sip_address" + + def test_sip_addresses_create_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.create_sip_address", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.sip_addresses.create( + name="x", calling_handler_resource_id="x" + ) + assert exc.value.status_code == 500 + + def test_sip_addresses_delete( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.sip_addresses.delete("test-id") + last = mock.last_request() + assert last.method == "DELETE" + assert last.matched_route == "fabric-addresses.delete_sip_address" + + def test_sip_addresses_delete_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.delete_sip_address", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.sip_addresses.delete("test-id") + assert exc.value.status_code == 500 + + def test_sip_addresses_get( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.sip_addresses.get("test-id") + last = mock.last_request() + assert last.method == "GET" + assert last.matched_route == "fabric-addresses.get_sip_address" + + def test_sip_addresses_get_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.get_sip_address", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.sip_addresses.get("test-id") + assert exc.value.status_code == 500 + + def test_sip_addresses_list( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.sip_addresses.list() + last = mock.last_request() + assert last.method == "GET" + assert last.matched_route == "fabric-addresses.list_sip_addresses" + + def test_sip_addresses_list_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.list_sip_addresses", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.sip_addresses.list() + assert exc.value.status_code == 500 + + def test_sip_addresses_update( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.sip_addresses.update("test-id") + last = mock.last_request() + assert last.method == "PATCH" + assert last.matched_route == "fabric-addresses.update_sip_address" + + def test_sip_addresses_update_error( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario("fabric-addresses.update_sip_address", 500, {"error": "x"}) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.sip_addresses.update("test-id") + assert exc.value.status_code == 500 + def test_sip_endpoints_create( self, signalwire_client: RestClient, mock: _MockHarness ) -> None: diff --git a/tests/unit/rest/test_fabric_addresses_mock.py b/tests/unit/rest/test_fabric_addresses_mock.py new file mode 100644 index 00000000..358b50fc --- /dev/null +++ b/tests/unit/rest/test_fabric_addresses_mock.py @@ -0,0 +1,223 @@ +"""Fabric address-management resources against the in-process ``mock_signalwire``. + +Covers the three resources folded into ``client.fabric`` by the +``rest-apis/fabric-addresses`` spec: + +* ``client.fabric.sip_addresses`` +* ``client.fabric.phone_number_addresses`` +* ``client.fabric.alias_addresses`` + +Each resource is exercised end-to-end over the shared HTTP mock (no +``requests`` mock.patch): every CRUD op asserts the on-the-wire +``(method, path, matched_route)`` the SDK built matches the spec, plus a +structured ``401 Unauthorized`` and ``404 Not Found`` error path (Q2 error +bodies) is proven to surface as ``SignalWireRestError`` with the right +``status_code``. +""" + +from __future__ import annotations + +import pytest + +from signalwire.rest.client import RestClient +from signalwire.rest._base import SignalWireRestError +from .conftest import _MockHarness + + +class TestSipAddresses: + """``client.fabric.sip_addresses.*`` — full CRUD, PATCH update.""" + + def test_list(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + body = signalwire_client.fabric.sip_addresses.list() + assert isinstance(body, dict) + last = mock.last_request() + assert last.method == "GET" + assert last.path == "/api/fabric/sip_addresses" + assert last.matched_route == "fabric-addresses.list_sip_addresses" + + def test_create(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.sip_addresses.create( + name="support-line", calling_handler_resource_id="res-1" + ) + last = mock.last_request() + assert last.method == "POST" + assert last.path == "/api/fabric/sip_addresses" + assert last.matched_route == "fabric-addresses.create_sip_address" + assert last.body["name"] == "support-line" + assert last.body["calling_handler_resource_id"] == "res-1" + + def test_get(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.sip_addresses.get("sip-1") + last = mock.last_request() + assert last.method == "GET" + assert last.path == "/api/fabric/sip_addresses/sip-1" + assert last.matched_route == "fabric-addresses.get_sip_address" + + def test_update_uses_patch( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.sip_addresses.update("sip-1", name="renamed") + last = mock.last_request() + assert last.method == "PATCH" + assert last.path == "/api/fabric/sip_addresses/sip-1" + assert last.matched_route == "fabric-addresses.update_sip_address" + assert last.body["name"] == "renamed" + + def test_delete(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.sip_addresses.delete("sip-1") + last = mock.last_request() + assert last.method == "DELETE" + assert last.path == "/api/fabric/sip_addresses/sip-1" + assert last.matched_route == "fabric-addresses.delete_sip_address" + + def test_unauthorized_401( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.list_sip_addresses", 401, {"error": "Unauthorized"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.sip_addresses.list() + assert exc.value.status_code == 401 + + def test_not_found_404( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.get_sip_address", 404, {"error": "Not Found"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.sip_addresses.get("missing") + assert exc.value.status_code == 404 + + +class TestPhoneNumberAddresses: + """``client.fabric.phone_number_addresses.*`` — assign-only CRUD, PATCH update.""" + + def test_list(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.phone_number_addresses.list() + last = mock.last_request() + assert last.method == "GET" + assert last.path == "/api/fabric/phone_number_addresses" + assert last.matched_route == "fabric-addresses.list_phone_number_addresses" + + def test_create(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.phone_number_addresses.create( + resource_id="res-1", handler_type="calling" + ) + last = mock.last_request() + assert last.method == "POST" + assert last.path == "/api/fabric/phone_number_addresses" + assert last.matched_route == "fabric-addresses.create_phone_number_address" + assert last.body["resource_id"] == "res-1" + assert last.body["handler_type"] == "calling" + + def test_get(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.phone_number_addresses.get("pn-1") + last = mock.last_request() + assert last.method == "GET" + assert last.path == "/api/fabric/phone_number_addresses/pn-1" + assert last.matched_route == "fabric-addresses.get_phone_number_address" + + def test_update_uses_patch( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.phone_number_addresses.update("pn-1", name="renamed") + last = mock.last_request() + assert last.method == "PATCH" + assert last.path == "/api/fabric/phone_number_addresses/pn-1" + assert last.matched_route == "fabric-addresses.update_phone_number_address" + + def test_delete(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.phone_number_addresses.delete("pn-1") + last = mock.last_request() + assert last.method == "DELETE" + assert last.path == "/api/fabric/phone_number_addresses/pn-1" + assert last.matched_route == "fabric-addresses.delete_phone_number_address" + + def test_unauthorized_401( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.list_phone_number_addresses", + 401, + {"error": "Unauthorized"}, + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.phone_number_addresses.list() + assert exc.value.status_code == 401 + + def test_not_found_404( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.get_phone_number_address", 404, {"error": "Not Found"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.phone_number_addresses.get("missing") + assert exc.value.status_code == 404 + + +class TestAliasAddresses: + """``client.fabric.alias_addresses.*`` — full CRUD, PATCH update.""" + + def test_list(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.alias_addresses.list() + last = mock.last_request() + assert last.method == "GET" + assert last.path == "/api/fabric/alias_addresses" + assert last.matched_route == "fabric-addresses.list_alias_addresses" + + def test_create(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.alias_addresses.create( + name="support", resource_id="res-1" + ) + last = mock.last_request() + assert last.method == "POST" + assert last.path == "/api/fabric/alias_addresses" + assert last.matched_route == "fabric-addresses.create_alias_address" + assert last.body["name"] == "support" + assert last.body["resource_id"] == "res-1" + + def test_get(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.alias_addresses.get("al-1") + last = mock.last_request() + assert last.method == "GET" + assert last.path == "/api/fabric/alias_addresses/al-1" + assert last.matched_route == "fabric-addresses.get_alias_address" + + def test_update_uses_patch( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + signalwire_client.fabric.alias_addresses.update("al-1", name="renamed") + last = mock.last_request() + assert last.method == "PATCH" + assert last.path == "/api/fabric/alias_addresses/al-1" + assert last.matched_route == "fabric-addresses.update_alias_address" + + def test_delete(self, signalwire_client: RestClient, mock: _MockHarness) -> None: + signalwire_client.fabric.alias_addresses.delete("al-1") + last = mock.last_request() + assert last.method == "DELETE" + assert last.path == "/api/fabric/alias_addresses/al-1" + assert last.matched_route == "fabric-addresses.delete_alias_address" + + def test_unauthorized_401( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.list_alias_addresses", 401, {"error": "Unauthorized"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.alias_addresses.list() + assert exc.value.status_code == 401 + + def test_not_found_404( + self, signalwire_client: RestClient, mock: _MockHarness + ) -> None: + mock.push_scenario( + "fabric-addresses.get_alias_address", 404, {"error": "Not Found"} + ) + with pytest.raises(SignalWireRestError) as exc: + signalwire_client.fabric.alias_addresses.get("missing") + assert exc.value.status_code == 404