From 5747ee509eff1b228e6bcfa2d1772638e9023c0f Mon Sep 17 00:00:00 2001 From: Denys Metelov Date: Tue, 10 Nov 2026 15:13:00 +0100 Subject: [PATCH 1/2] feat: follow the latest changes of the 2026-10-22 API document - Inbound SIP configuration: codecs (Codec names in priority order) replaces codec ids; new iceMode, rtcpMuxMode and rtcpFeedbackMode. - Voice OUT trunks: new codecs (VoiceOutCodec), iceMode, rtcpMuxMode and rtcpFeedbackMode. - Lowercase values: Voice IN trunk Pop, proof type entity_type, export filters service_type (ExportServiceType), trunk_type and status. - DidwwApiException.getRetryAfter() from the Retry-After of the answer, on API calls, export downloads and encrypted file uploads; a 429 is not retried. uploadEncryptedFile throws DidwwApiException on an error status. - Price file and export downloads use a short temp file name, so a long destination name no longer fails. - README, FILTERS.md and examples: requirement mandatory fields, price file columns, migration notes. --- FILTERS.md | 10 +- README.md | 70 ++++++-- examples/README.md | 4 +- .../examples/DidTrunkAssignmentExample.java | 2 +- .../EmergencyRequirementsExample.java | 7 +- .../com/didww/examples/ExportsSmsExample.java | 17 +- .../IdentityAddressProofsExample.java | 4 +- .../didww/examples/PricesDownloadExample.java | 2 +- .../com/didww/examples/TrunksExample.java | 5 +- .../examples/VoiceInTrunkGroupsExample.java | 2 +- .../VoiceInTrunkSipRegistrationExample.java | 2 +- .../didww/examples/VoiceOutTrunksExample.java | 7 + src/main/java/com/didww/sdk/DidwwClient.java | 32 ++-- .../sdk/exception/DidwwApiException.java | 49 +++++ .../com/didww/sdk/internal/AtomicFile.java | 11 +- .../com/didww/sdk/internal/RetryAfter.java | 31 ++++ .../com/didww/sdk/prices/PriceFileReader.java | 2 +- .../com/didww/sdk/prices/PricesService.java | 18 +- .../sdk/repository/ReadOnlyRepository.java | 10 +- .../sdk/resource/AddressRequirement.java | 10 ++ .../sdk/resource/EmergencyRequirement.java | 10 ++ .../java/com/didww/sdk/resource/Export.java | 14 +- .../java/com/didww/sdk/resource/Identity.java | 4 + .../com/didww/sdk/resource/ProofType.java | 1 + .../com/didww/sdk/resource/VoiceOutTrunk.java | 61 +++++++ .../configuration/SipConfiguration.java | 21 ++- .../com/didww/sdk/resource/enums/Codec.java | 36 ++-- .../sdk/resource/enums/ExportServiceType.java | 9 + .../sdk/resource/enums/ExportTrunkType.java | 8 +- .../com/didww/sdk/resource/enums/IceMode.java | 13 ++ .../com/didww/sdk/resource/enums/Pop.java | 14 +- .../sdk/resource/enums/RtcpFeedbackMode.java | 13 ++ .../didww/sdk/resource/enums/RtcpMuxMode.java | 13 ++ .../sdk/resource/enums/VoiceOutCodec.java | 20 +++ .../didww/sdk/EncryptedFileUploadTest.java | 5 +- .../com/didww/sdk/ExportDownloadTest.java | 37 ++++ .../com/didww/sdk/TooManyRequestsTest.java | 168 ++++++++++++++++++ .../didww/sdk/prices/PriceFileReaderTest.java | 15 +- .../com/didww/sdk/prices/PriceFileTest.java | 36 +++- .../sdk/resource/AddressRequirementTest.java | 11 +- .../resource/EmergencyRequirementTest.java | 9 +- .../com/didww/sdk/resource/ExportTest.java | 5 +- .../com/didww/sdk/resource/ProofTypeTest.java | 3 +- .../didww/sdk/resource/VoiceInTrunkTest.java | 37 +++- .../didww/sdk/resource/VoiceOutTrunkTest.java | 20 ++- .../resource/enums/EnumWireValuesTest.java | 47 +++++ .../com/didww/sdk/support/ObservedBody.java | 48 +++++ .../fixtures/address_requirements/index.json | 20 +-- .../fixtures/address_requirements/show.json | 34 ++-- .../emergency_requirements/index.json | 15 +- .../fixtures/emergency_requirements/show.json | 11 +- .../fixtures/exports/create_sms_in.json | 4 +- .../exports/create_sms_in_request.json | 2 +- .../fixtures/prices/voice_in_prices.csv.gz | Bin 213 -> 191 bytes .../fixtures/prices/voice_out_prices.csv.gz | Bin 296 -> 284 bytes .../resources/fixtures/proof_types/index.json | 10 +- .../resources/fixtures/proofs/create.json | 2 +- .../voice_in_trunk_groups/create.json | 26 +-- .../fixtures/voice_in_trunk_groups/index.json | 30 ++-- .../create_sip_with_rerouting.json | 14 +- .../create_sip_with_rerouting_request.json | 2 +- .../voice_in_trunks/create_with_pop.json | 2 +- .../create_with_pop_request.json | 2 +- .../fixtures/voice_in_trunks/index.json | 14 +- .../fixtures/voice_in_trunks/update_sip.json | 12 +- .../voice_in_trunks/update_sip_request.json | 5 +- .../fixtures/voice_out_trunks/create.json | 8 + .../voice_out_trunks/create_request.json | 2 +- .../voice_out_trunks/create_twilio.json | 8 + .../fixtures/voice_out_trunks/index.json | 16 ++ .../fixtures/voice_out_trunks/show.json | 8 + .../voice_out_trunks/show_ip_only.json | 8 + .../voice_out_trunks/show_twilio.json | 8 + .../fixtures/voice_out_trunks/update.json | 8 + .../voice_out_trunks/update_request.json | 4 + 75 files changed, 1014 insertions(+), 234 deletions(-) create mode 100644 src/main/java/com/didww/sdk/internal/RetryAfter.java create mode 100644 src/main/java/com/didww/sdk/resource/enums/ExportServiceType.java create mode 100644 src/main/java/com/didww/sdk/resource/enums/IceMode.java create mode 100644 src/main/java/com/didww/sdk/resource/enums/RtcpFeedbackMode.java create mode 100644 src/main/java/com/didww/sdk/resource/enums/RtcpMuxMode.java create mode 100644 src/main/java/com/didww/sdk/resource/enums/VoiceOutCodec.java create mode 100644 src/test/java/com/didww/sdk/TooManyRequestsTest.java create mode 100644 src/test/java/com/didww/sdk/resource/enums/EnumWireValuesTest.java create mode 100644 src/test/java/com/didww/sdk/support/ObservedBody.java diff --git a/FILTERS.md b/FILTERS.md index 5903b399..2478946b 100644 --- a/FILTERS.md +++ b/FILTERS.md @@ -217,7 +217,7 @@ Pass values URL-encoded as strings. Where the server accepts arrays, comma-separ ## ProofType — `GET /v3/proof_types` -- `filter[entity_type]` +- `filter[entity_type]` — `address`, `business` or `personal` ## Region — `GET /v3/regions` @@ -282,7 +282,7 @@ Pass values URL-encoded as strings. Where the server accepts arrays, comma-separ ## Export filters — `filters` of `POST /v3/exports` -Set them as a map with `Export#setFilters(Map)`. `from` and `to` are required for every export type, as `YYYY-MM-DD HH:MM:SS` in UTC (`from` inclusive, `to` exclusive). Where a key takes several values, pass a `List`; it is sent as a JSON array. Any key that the export type does not accept is rejected. +Set them as a map with `Export#setFilters(Map)`. `from` and `to` are required for every export type, as `YYYY-MM-DD HH:MM:SS` in UTC (`from` inclusive, `to` exclusive). Where a key takes several values, pass a `List` (of strings, or of `ExportTrunkType` constants for `trunk_type`); it is sent as a JSON array. An enum constant of the SDK, such as `ExportServiceType.A2P`, is sent as its wire value (`a2p`); `name()` is not the wire form. The values of `service_type`, `trunk_type` and `status` are lowercase. Any key that the export type does not accept is rejected. | Key | Export types | |---|---| @@ -300,11 +300,11 @@ Set them as a map with `Export#setFilters(Map)`. `from` and `to` | `source_address_cont` | `sms_in`, `sms_out` | | `destination_address_eq` | `sms_in`, `sms_out` | | `destination_address_cont` | `sms_in`, `sms_out` | -| `status` | `sms_in` (`Success`, `Failed`, `Queued`), `sms_out` (`Routing Error`, `In Progress`, `Queued`, `Failed`, `Success`) | +| `status` | `sms_in` (`success`, `failed`, `queued`), `sms_out` (`routing_error`, `in_progress`, `queued`, `failed`, `success`) | | `sms_id` | `sms_in`, `sms_out` | | `sms_trunk.id` | `sms_in`, `sms_out` — one id or a list of ids | -| `trunk_type` | `sms_in` — one or a list of `ExportTrunkType` values in their wire form, e.g. `HTTP IN` | -| `service_type` | `sms_out` — `a2p` or `p2p` | +| `trunk_type` | `sms_in` — one or a list of `ExportTrunkType` values (`smpp_smsc`, `smpp_esme`, `sms_to_email`, `http_in`) | +| `service_type` | `sms_out` — an `ExportServiceType` value, `a2p` or `p2p` | | `sender_id_verification.id` | `sms_out` — one id or a list of ids | ## Price lookups — `GET /v3/…/lookup/prices` diff --git a/README.md b/README.md index bc1b0873..b35da365 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,8 @@ Balance balance = client.balance().find().getData(); `DidGroup`, `AddressRequirement`, `EmergencyRequirement` and `EmergencyCallingService` carry `getNumberType()`, one of the `NumberType` values (`LOCAL`, `NATIONAL`, `MOBILE`, `TOLL_FREE`, `SHARED_COST`, `GLOBAL`; in filters use the wire form, such as `toll_free`). `DidGroup#getIsDemo()` flags demo groups. `NumberType` replaces the `DidGroupType` resource and the `did_group_type` relationship, which API `2026-10-22` no longer has. The `pops` resource is gone too: a Voice IN trunk carries its point of presence in the `pop` attribute (see [Voice In Trunks](#voice-in-trunks)). +The mandatory fields of `AddressRequirement` (`getPersonalMandatoryFields()`, `getBusinessMandatoryFields()`) and of `EmergencyRequirement` (`getAddressMandatoryFields()`, `getPersonalMandatoryFields()`, `getBusinessMandatoryFields()`) list the fields as the API spells them, such as `birth_date`, `id_number`, `country` or `area`, and are empty when nothing is mandatory. `tax_id` stands for `Identity#getVatId()`. `ProofType#getEntityType()` is `address`, `business` or `personal`. + ### DIDs ```java @@ -423,7 +425,8 @@ trunk.setPop(Pop.FRA); // optional; null lets DIDWW choose SipConfiguration sip = new SipConfiguration(); sip.setHost("sip.example.com"); sip.setPort(5060); -sip.setCodecIds(Arrays.asList(Codec.PCMU, Codec.PCMA)); +sip.setCodecs(Arrays.asList(Codec.PCMA, Codec.PCMU, Codec.TELEPHONE_EVENT)); +sip.setIceMode(IceMode.ACCEPT); sip.setTransportProtocolId(TransportProtocol.UDP); trunk.setConfiguration(sip); @@ -437,7 +440,9 @@ client.voiceInTrunks().update(created); client.voiceInTrunks().delete(created.getId()); ``` -`pop` is a plain attribute, one of the `com.didww.sdk.resource.enums.Pop` values (`NYC`, `FRA`, `LAC`, `MIA`, `SG`, `HK`, `AMS`). Leave it `null` to let DIDWW choose; `setPop(null)` on a built or fetched trunk sends `"pop": null`. A value this SDK does not know reads as `null`. It is no longer a relationship and cannot be included. +`codecs` lists the codecs offered on calls to the trunk by name, in priority order: the first has the highest priority. It replaces `codec_ids`; the names are the `Codec` values. Left out, the trunk offers `pcmu`, `pcma`, `g729`, `g723` and `telephone_event`. `setIceMode`, `setRtcpMuxMode` and `setRtcpFeedbackMode` take the `IceMode`, `RtcpMuxMode` and `RtcpFeedbackMode` values `DISABLED` (the default), `ACCEPT` or `INITIATE`. + +`pop` is a plain attribute, one of the `com.didww.sdk.resource.enums.Pop` values (`NYC`, `FRA`, `LAC`, `MIA`, `SG`, `HK`, `AMS`, sent as `nyc`, `fra`, `lac`, `mia`, `sg`, `hk`, `ams`). Leave it `null` to let DIDWW choose; `setPop(null)` on a built or fetched trunk sends `"pop": null`. A value this SDK does not know reads as `null`. It is no longer a relationship and cannot be included. ```java VoiceInTrunk popUpdate = new VoiceInTrunk().withId("trunk-uuid"); @@ -497,6 +502,8 @@ group.setCapacityLimit(50); VoiceInTrunkGroup created = client.voiceInTrunkGroups().create(group).getData(); ``` +A Voice In trunk group cannot be deleted while DIDs are routed to it or to one of its trunks (422): unroute those DIDs, or route them elsewhere, first. + ### Voice Out Trunks > **Note:** Voice Out Trunks require additional account configuration. Contact DIDWW support to enable. @@ -511,6 +518,7 @@ Voice Out Trunks use a polymorphic `authenticationMethod` (2026-04-16). Three ty ```java import com.didww.sdk.resource.enums.DefaultDstAction; import com.didww.sdk.resource.enums.OnCliMismatchAction; +import com.didww.sdk.resource.enums.VoiceOutCodec; import com.didww.sdk.resource.authenticationmethod.CredentialsAndIpAuthenticationMethod; // NOTE: 203.0.113.0/24 is RFC 5737 TEST-NET-3 documentation space. @@ -523,11 +531,14 @@ voTrunk.setName("My Outbound Trunk"); voTrunk.setAuthenticationMethod(auth); voTrunk.setDefaultDstAction(DefaultDstAction.ALLOW_ALL); voTrunk.setOnCliMismatchAction(OnCliMismatchAction.REJECT_CALL); +voTrunk.setCodecs(Arrays.asList(VoiceOutCodec.OPUS, VoiceOutCodec.PCMA, VoiceOutCodec.TELEPHONE_EVENT)); VoiceOutTrunk created = client.voiceOutTrunks().create(voTrunk).getData(); // created.getAuthenticationMethod().getUsername() -- server-generated // created.getAuthenticationMethod().getPassword() -- server-generated ``` +`codecs` lists the codecs offered on calls from the trunk by name, in priority order: the first has the highest priority. The names are the `VoiceOutCodec` values, which differ from the `Codec` values of an inbound SIP trunk. Left out, the trunk offers `pcma`, `pcmu` and `telephone_event`. `setIceMode`, `setRtcpMuxMode` and `setRtcpFeedbackMode` take `DISABLED`, `ACCEPT` (the default) or `INITIATE`. + ### SMS Trunks An SMS trunk delivers inbound messages, accepts outbound ones, or both. Its configuration is one of five classes in `com.didww.sdk.resource.smstrunkconfiguration`: @@ -759,6 +770,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; import java.time.ZoneOffset; +import com.didww.sdk.resource.enums.ExportServiceType; import com.didww.sdk.resource.enums.ExportType; // Dates are "YYYY-MM-DD HH:MM:SS" in UTC: from is inclusive, to is exclusive and not in the future @@ -778,7 +790,7 @@ Export created = client.exports().create(export).getData(); Map smsFilters = new HashMap<>(); smsFilters.put("from", from); smsFilters.put("to", to); -smsFilters.put("service_type", "a2p"); +smsFilters.put("service_type", ExportServiceType.A2P); // sent as "a2p" smsFilters.put("sms_trunk.id", Arrays.asList("sms-trunk-uuid")); Export smsOut = new Export(); smsOut.setExportType(ExportType.SMS_OUT); @@ -797,15 +809,17 @@ if (completed.isCompleted()) { } ``` -The export downloads send the token only to the origin (scheme, host and port) of the JSON:API base URL or of the environment, so export URLs, which the API builds on the environment's host, are still downloaded when `baseUrl(...)` points elsewhere; that request goes directly to the environment's host. A URL on any other origin throws a `DidwwClientException` and nothing is sent. Only a 200 answer is written, as it arrives, without holding the file in memory; redirects are not followed, and any other status, a 3xx or a 204 included, throws a `DidwwApiException` (see [Error Handling](#error-handling)). The `Path` overloads write to a temporary file next to the destination, which replaces it only once the whole file has arrived (or has been decompressed); the `OutputStream` overloads write into your stream, which is not closed and may hold part of the file after a failure. +The export downloads send the token only to the origin (scheme, host and port) of the JSON:API base URL or of the environment, so export URLs, which the API builds on the environment's host, are still downloaded when `baseUrl(...)` points elsewhere; that request goes directly to the environment's host. A URL on any other origin throws a `DidwwClientException` and nothing is sent. Only a 200 answer is written, as it arrives, without holding the file in memory; redirects are not followed, and any other status, a 3xx or a 204 included, throws a `DidwwApiException` (see [Error Handling](#error-handling)). The `Path` overloads write to a temporary file `.didww-<16 hex digits>.tmp` in the directory of the destination, which replaces it only once the whole file has arrived (or has been decompressed); the `OutputStream` overloads write into your stream, which is not closed and may hold part of the file after a failure. `from` and `to` are required for every export type (`cdr_in`, `cdr_out`, `sms_in`, `sms_out`). The other filter keys depend on the type: - `cdr_in`: `did_number`, `source_number`, `source_number_out`, `source_country_iso`; - `cdr_out`: `voice_out_trunk.id`, `source`, `cli`, `destination`; - `sms_in` and `sms_out`: `source_address_eq`, `source_address_cont`, `destination_address_eq`, `destination_address_cont`, `status`, `sms_id`, `sms_trunk.id` (a string or a list of strings); -- `sms_in` only: `trunk_type`, one or more of the `ExportTrunkType` values in their wire form (`SMPP SMSC`, `SMPP ESME`, `Email`, `HTTP IN`); -- `sms_out` only: `service_type` (`a2p` or `p2p`) and `sender_id_verification.id` (a string or a list of strings). +- `sms_in` only: `trunk_type`, one or more of the `ExportTrunkType` values; +- `sms_out` only: `service_type`, one of the `ExportServiceType` values, and `sender_id_verification.id` (a string or a list of strings). + +Filter values are lowercase, and an enum constant of the SDK is sent as its wire value: `service_type` is `a2p` or `p2p`, `trunk_type` is one or more of `smpp_smsc`, `smpp_esme`, `sms_to_email` and `http_in`, and `status` is `success`, `failed` or `queued` for `sms_in`, and `routing_error`, `in_progress`, `queued`, `failed` or `success` for `sms_out`. The Javadoc of `Export#setFilters` lists the keys of every type. @@ -907,7 +921,7 @@ System.out.println(file.isNotModified() // Read the rows, keyed by the header line try (Stream> rows = PriceFileReader.rows(path)) { - rows.limit(5).forEach(row -> System.out.println(row.get("Destination") + ": " + row.get("Rate (USD, excl. VAT)"))); + rows.limit(5).forEach(row -> System.out.println(row.get("Destination") + ": " + row.get("Rate"))); } // Lower-level calls @@ -922,11 +936,12 @@ try (OutputStream out = Files.newOutputStream(Path.of("/tmp/voice_in_prices.csv. ``` - `fetch(path)` sends the modification time of the file at `path` as `If-Modified-Since`, when the file is there; an unchanged price file is answered 304 and the file is kept. A file written by `downloadToFile` or `fetch` gets the `Last-Modified` of the response as its modification time, so the date matches the copy on disk. `fetch` trusts the modification time of any file at `path`: a file copied or changed by other means can be answered 304 and kept, so replace such a file with `downloadToFile`. -- `downloadToFile` and `fetch` stream the body into a temporary file next to `path`, unique to the call, which gets the permissions of a new file (the umask on POSIX systems), is flushed to the disk and replaces `path` atomically only on a complete 200. A 304 or an error leaves an existing file untouched, the temporary file is always removed, and concurrent downloads to one path do not mix their bytes. A directory that does not exist throws an `UncheckedIOException` without a request. **On Windows the replacement fails while `path` is open**, for example by a `PriceFileReader` stream that has not been closed. +- `downloadToFile` and `fetch` stream the body into a temporary file `.didww-<16 hex digits>.tmp` in the directory of `path`, unique to the call, which gets the permissions of a new file (the umask on POSIX systems), is flushed to the disk and replaces `path` atomically only on a complete 200. A 304 or an error leaves an existing file untouched, the temporary file is always removed, and concurrent downloads to one path do not mix their bytes. A directory that does not exist throws an `UncheckedIOException` without a request. **On Windows the replacement fails while `path` is open**, for example by a `PriceFileReader` stream that has not been closed. - `download(destination)` and `download(destination, ifModifiedSince)` write the body into `destination` only on a 200, as it arrives, so after a transfer error `destination` may hold part of it. The stream is not closed. An `IOException` of `destination` is thrown as an `UncheckedIOException` with it as the cause. - A body shorter than its `Content-Length` throws `PricesErrorKind.CONNECTION_ERROR`; it never replaces a file. - `head` and the downloads return a `PriceFile`: `getStatusCode()`, `getLastModified()` (`null` when absent), `getContentLength()`, `getFileName()` (as sent: check it before using it as a path), `getBytesWritten()` and `isNotModified()`. -- `PriceFileReader.rows(path)` and `PriceFileReader.rows(inputStream)` gunzip a downloaded file and return a lazy `Stream` of rows, each a `Map` keyed by the header line, as served (for example `Rate (USD, excl. VAT)`), in header order. A UTF-8 byte order mark and empty lines are skipped. Input that is not gzip (`ZipException`), gzip data cut short, an empty input included (`EOFException`), or a row whose field count differs from the header (an `IOException` whose message starts with `Price file:`) throws an `UncheckedIOException` with that cause while the stream is consumed, so rows before the error have already been passed on. Data after the end of the gzip data is ignored unless it starts another gzip member, so a later member that is cut short or corrupt raises the error too. Close the stream: `rows(path)` opens the file, `rows(inputStream)` leaves your stream open. +- `PriceFileReader.rows(path)` and `PriceFileReader.rows(inputStream)` gunzip a downloaded file and return a lazy `Stream` of rows, each a `Map` keyed by the header line, as served, in header order. A UTF-8 byte order mark and empty lines are skipped. Input that is not gzip (`ZipException`), gzip data cut short, an empty input included (`EOFException`), or a row whose field count differs from the header (an `IOException` whose message starts with `Price file:`) throws an `UncheckedIOException` with that cause while the stream is consumed, so rows before the error have already been passed on. Data after the end of the gzip data is ignored unless it starts another gzip member, so a later member that is cut short or corrupt raises the error too. Close the stream: `rows(path)` opens the file, `rows(inputStream)` leaves your stream open. +- The columns, prices in USD with VAT excluded. Voice IN: `Country`, `Src country name`, `Source type`, `Destination`, `Rate`, `Connection fee`, `Initial interval`, `Next interval` and `Reject calls`. Voice OUT: `Category`, `Country`, `Network`, `Prefix` (one prefix per row), `Rate`, `Connection fee`, `Initial increment`, `Recurring increment`, `Reject calls`, `Next rate`, `Next connection fee`, `Next initial increment`, `Next recurring increment`, `Effective date` and `Comment`. SMS OUT: `Source prefix`, `Destination prefix`, `Country`, `MCC`, `MCC name`, `MNC`, `MNC name`, `Reject SMS` (`true` or `false`) and `Price`. SMS IN: `Source prefix`, `Source number length min`, `Source number length max`, `Destination prefix`, `Destination number length min`, `Destination number length max`, `Country` and `Price`. - Only `Last-Modified` and `If-Modified-Since` are used, in whole seconds. The service sends no `ETag`, and the SDK never sends `If-None-Match`, which would make the service ignore `If-Modified-Since`. - A 503 on a price file is retried up to 2 times when its `Retry-After` is 60 seconds or less, after waiting that long (at least 1 s) plus a small random delay so that clients do not retry in step. Otherwise it throws `PricesErrorKind.SERVICE_UNAVAILABLE` with `getRetryAfter()`. Lookups and other statuses, 429 included, are not retried. - A 404 `price_not_found` throws `PRICE_NOT_FOUND`; any other 404, `price_plan_not_assigned` included, and any 404 on `head`, which has no body, throws `PRICE_PLAN_NOT_ASSIGNED`. `DidwwPricesException#getCode()` holds the code of the answer, or `price_plan_not_assigned` for a 404 without one. @@ -1083,12 +1098,25 @@ try { | Exception | Thrown for | |-----------|------------| -| `DidwwApiException` | An error status of the JSON:API repositories and of the export downloads, with `getHttpStatus()` and `getErrors()`. | -| `DidwwClientException` | A failure on the client side: a JSON:API call that fails in transport (the `IOException` is the cause) or whose resource cannot be serialized, an error status of `uploadEncryptedFile`, an upload or price lookup answer that cannot be parsed, a refused export URL, an export download that breaks off or cannot be decompressed. | +| `DidwwApiException` | An error status of the JSON:API repositories, the export downloads and `uploadEncryptedFile`, with `getHttpStatus()`, `getErrors()` and `getRetryAfter()`. | +| `DidwwClientException` | A failure on the client side: a JSON:API call or upload that fails in transport (the `IOException` is the cause) or whose resource cannot be serialized, an upload or price lookup answer that cannot be parsed, a refused export URL, an export download that breaks off or cannot be decompressed. | | `DidwwAuthException` | The token service, from any call that needs a new token. | | `DidwwPricesException` | The prices services. | -`DidwwApiException` also reports the authentication failures of the API: a 401 still answered after the token was replaced once (`getHttpStatus()` 401), and a 403 such as `insufficient_scope` for a write with a `READ` token, which is never retried. `uploadEncryptedFile` reports any error status, including a 401 after the retry and a 403, as a `DidwwClientException` whose message holds the HTTP status. The export downloads do not follow redirects: any status other than 200, such as a 3xx or a 204, throws a `DidwwApiException`. +`DidwwApiException` also reports the authentication failures of the API: a 401 still answered after the token was replaced once (`getHttpStatus()` 401), and a 403 such as `insufficient_scope` for a write with a `READ` token, which is never retried. The export downloads do not follow redirects: any status other than 200, such as a 3xx or a 204, throws a `DidwwApiException`. + +A 429 (`too_many_requests`) means the request budget of the account for the current window is spent. The repositories, the export downloads and `uploadEncryptedFile` throw it as a `DidwwApiException` with `getHttpStatus()` 429 and `getRetryAfter()`, the `Retry-After` of the response as a `Duration` (`null` when it is missing or not a number of seconds; any other error status carries it too). The SDK does not retry it, as a write may not be safe to send twice: wait `getRetryAfter()` before sending the request again. + +```java +try { + client.dids().list(); +} catch (DidwwApiException e) { + if (e.getHttpStatus() != 429) { + throw e; + } + System.out.println("API rate limit, retry after " + e.getRetryAfter()); +} +``` The token service and prices errors carry a kind, which `getKind()` tells apart: @@ -1264,14 +1292,16 @@ The SDK provides enum classes in `com.didww.sdk.resource.enums`: `CallbackMethod`, `IdentityType`, `OrderStatus`, `ExportType`, `ExportStatus`, `CliFormat`, `OnCliMismatchAction`\*, `MediaEncryptionMode`, `DefaultDstAction`, `VoiceOutTrunkStatus`, `EmergencyCallingServiceStatus`, `EmergencyVerificationStatus`, `DiversionRelayPolicy`, -`TransportProtocol`, `Codec`, `RxDtmfFormat`, `TxDtmfFormat`, `SstRefreshMethod`, -`ReroutingDisconnectCode`, `Feature`, `AreaLevel`, `AddressVerificationStatus`, `StirShakenMode`, -`NumberType`, `Pop`, `SenderIdVerificationStatus`, `SenderType`, `SmsHttpMethod`, `SmsBodyType`, -`SmsServerLocation`, `ExportTrunkType` +`TransportProtocol`, `Codec`, `VoiceOutCodec`, `IceMode`, `RtcpMuxMode`, `RtcpFeedbackMode`, +`RxDtmfFormat`, `TxDtmfFormat`, `SstRefreshMethod`, `ReroutingDisconnectCode`, `Feature`, `AreaLevel`, +`AddressVerificationStatus`, `StirShakenMode`, `NumberType`, `Pop`, `SenderIdVerificationStatus`, `SenderType`, +`SmsHttpMethod`, `SmsBodyType`, `SmsServerLocation`, `ExportTrunkType`, `ExportServiceType` \* `REPLACE_CLI` and `RANDOMIZE_CLI` require account configuration. -`ExportType` includes `SMS_IN` and `SMS_OUT`. `VoiceOutPriceCategory` is in `com.didww.sdk.prices`, and `DidwwScope` in `com.didww.sdk.auth`. A value the API sends that this SDK does not know reads as `null`. +`Codec` holds the codecs of an inbound SIP trunk (`SipConfiguration#setCodecs`) and `VoiceOutCodec` those of a Voice OUT trunk (`VoiceOutTrunk#setCodecs`); the two lists differ. `IceMode`, `RtcpMuxMode` and `RtcpFeedbackMode` serve both. + +`ExportType` includes `SMS_IN` and `SMS_OUT`; `ExportTrunkType` and `ExportServiceType` are the values of the `trunk_type` and `service_type` filters of SMS exports. `VoiceOutPriceCategory` is in `com.didww.sdk.prices`, and `DidwwScope` in `com.didww.sdk.auth`. A value the API sends that this SDK does not know reads as `null`. The wire value of a string enum is declared with Jackson's `@JsonProperty` only: `name()` is not the wire form (`NumberType.TOLL_FREE.name()` is `TOLL_FREE`, the API value is `toll_free`). Write the wire value in filters, such as `filter("number_type", "toll_free")` or `filter("status", "changes_required")`. @@ -1285,13 +1315,15 @@ Most code keeps working: `DidwwClient.builder().credentials(new DidwwCredentials - **API version.** `X-DIDWW-API-Version` is no longer sent: the version is requested in the token. The default is `2026-10-22`, and an earlier version makes `build()` throw an `IllegalArgumentException`. - **Validation.** `build()` throws an `IllegalArgumentException` for an invalid API key, API version or service URL (see [Configuration](#configuration)), which 4.x accepted. - **Removed resources.** `client.pops()`, `client.didGroupTypes()`, `com.didww.sdk.resource.Pop` and `com.didww.sdk.resource.DidGroupType` are removed, with the `did_group_type` relationship and include. `DidGroup`, `AddressRequirement`, `EmergencyRequirement` and `EmergencyCallingService` have `getNumberType()` instead of `getDidGroupType()`; filter with `number_type` instead of `did_group_type.id`, and with `did_group.number_type` on available DIDs. +- **Codecs.** `SipConfiguration#setCodecIds` and `getCodecIds` (`codec_ids`) are replaced by `setCodecs` and `getCodecs` (`codecs`), the codec names in priority order. `Codec` is no longer an integer enum: its constants are sent by name (`PCMU` as `pcmu`), and `AMR` and `AMR_WB` are new. Voice OUT trunks have their own codec list, `VoiceOutCodec`. Both trunk kinds gain `iceMode`, `rtcpMuxMode` and `rtcpFeedbackMode`. +- **Requirements and proof types.** The mandatory fields of `AddressRequirement` and `EmergencyRequirement` are field names as the API spells them (such as `birth_date` or `tax_id`, not labels such as `Birth Date`) and never `null`. `ProofType#getEntityType()` is lowercase: `address`, `business` or `personal`. - **Point of presence.** `VoiceInTrunk#setPop` takes the enum `com.didww.sdk.resource.enums.Pop` (for example `Pop.FRA`), and `getPop()` returns it; `pop` is an attribute, no longer a relationship, and cannot be included. - **Numbers in untyped maps.** Decimal numbers that the SDK reads into a `Map` or a Jackson node, such as `ApiResponse#getMeta()`, `GenericAuthenticationMethod#getAttributes()` (an unknown Voice OUT trunk authentication method), the `source` and `meta` of the errors of a `DidwwApiException`, `Export#getFilters()` and the maps of `HttpInConfiguration`, are now `BigDecimal` with their exact value, not `Double`. Integers are unchanged. - **HTTP defaults.** 4.x used OkHttp's defaults: 10 s timeouts and redirects followed. 5.x opens TCP connections within 10 s, waits up to 60 s for a proxy tunnel, the TLS handshake, the response headers and between two reads or writes, follows no redirects, sends and stores no cookies and ignores the `Authenticator` and cache of your builder (see [HTTP client](#http-client-proxy-tls-timeouts)). The timeouts of your own `httpClientBuilder` are kept as before. -- **Export download and file upload.** `uploadEncryptedFile` now goes through your configured HTTP client (proxy, TLS, interceptors) with the bearer token; 4.x used a separate default client. `downloadExport` and `downloadAndDecompressExport` download only from the API origin or the environment origin (a `DidwwClientException` otherwise, without a request), succeed only on a 200 (any other status, a 2xx included, now throws a `DidwwApiException` with the status and the parsed errors, where 4.x threw a `DidwwClientException`), follow no redirects, stream the file instead of reading it into memory, and replace the destination file only once it is complete; a destination file that cannot be written (missing directory, permissions, full disk) now throws an `UncheckedIOException` with the `IOException` as its cause, not a `DidwwClientException`; new overloads write to an `OutputStream`. +- **Export download and file upload.** `uploadEncryptedFile` now goes through your configured HTTP client (proxy, TLS, interceptors) with the bearer token; 4.x used a separate default client. An error status of `uploadEncryptedFile` now throws a `DidwwApiException` with the status, the parsed errors and `getRetryAfter()`, where 4.x threw a `DidwwClientException`. `downloadExport` and `downloadAndDecompressExport` download only from the API origin or the environment origin (a `DidwwClientException` otherwise, without a request), succeed only on a 200 (any other status, a 2xx included, now throws a `DidwwApiException` with the status and the parsed errors, where 4.x threw a `DidwwClientException`), follow no redirects, stream the file instead of reading it into memory, and replace the destination file only once it is complete; a destination file that cannot be written (missing directory, permissions, full disk) now throws an `UncheckedIOException` with the `IOException` as its cause, not a `DidwwClientException`; new overloads write to an `OutputStream`. - **Exceptions.** `DidwwApiException` and `DidwwClientException` now extend the abstract `DidwwException` (still a `RuntimeException`), the base of the new `DidwwAuthException` and `DidwwPricesException`. -New in 5.0.0: [SMS trunks](#sms-trunks), [SMS trunk groups](#sms-trunk-groups), [Sender ID verifications](#sender-id-verifications), [DID SMS routing](#sms-routing), `sms_in` and `sms_out` [exports](#exports), [prices](#prices), `DidGroup#getIsDemo()`, `Identity` company website, and the `apiVersion`, `scope`, `tokenStore`, `logger` and service URL methods of the builder. +New in 5.0.0: `DidwwApiException#getRetryAfter()` for a 429 of the API (see [Error Handling](#error-handling)), codecs and ICE, RTCP multiplexing and RTCP feedback modes of [Voice Out trunks](#voice-out-trunks), [SMS trunks](#sms-trunks), [SMS trunk groups](#sms-trunk-groups), [Sender ID verifications](#sender-id-verifications), [DID SMS routing](#sms-routing), `sms_in` and `sms_out` [exports](#exports), [prices](#prices), `DidGroup#getIsDemo()`, `Identity` company website, and the `apiVersion`, `scope`, `tokenStore`, `logger` and service URL methods of the builder. ## Development diff --git a/examples/README.md b/examples/README.md index 0755b788..85c439c8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -40,7 +40,7 @@ DIDWW_API_KEY=your_api_key ./gradlew runExample -PexampleClass=com.didww.example | [`com.didww.examples.RegionsExample`](src/main/java/com/didww/examples/RegionsExample.java) | Lists regions with filters/includes and fetches a specific region. | | [`com.didww.examples.DidGroupsExample`](src/main/java/com/didww/examples/DidGroupsExample.java) | Fetches DID groups with included SKUs, number type, demo flag and group details. | | [`com.didww.examples.DidsExample`](src/main/java/com/didww/examples/DidsExample.java) | Updates DID routing/capacity by assigning trunk and capacity pool. | -| [`com.didww.examples.TrunksExample`](src/main/java/com/didww/examples/TrunksExample.java) | Lists trunks, creates SIP (with a point of presence) and PSTN trunks, updates and deletes them. | +| [`com.didww.examples.TrunksExample`](src/main/java/com/didww/examples/TrunksExample.java) | Lists trunks, creates SIP (with a point of presence, codecs and ICE mode) and PSTN trunks, updates and deletes them. | | [`com.didww.examples.SharedCapacityGroupsExample`](src/main/java/com/didww/examples/SharedCapacityGroupsExample.java) | Creates a shared capacity group in a capacity pool. | | [`com.didww.examples.OrdersExample`](src/main/java/com/didww/examples/OrdersExample.java) | Lists orders and creates/cancels a DID order using live SKU lookup. | | [`com.didww.examples.OrdersSkuExample`](src/main/java/com/didww/examples/OrdersSkuExample.java) | Creates a DID order by SKU resolved from DID groups. | @@ -53,7 +53,7 @@ DIDWW_API_KEY=your_api_key ./gradlew runExample -PexampleClass=com.didww.example | [`com.didww.examples.IdentityAddressProofsExample`](src/main/java/com/didww/examples/IdentityAddressProofsExample.java) | Creates identity and address, encrypts and uploads files, attaches proofs to both. | | [`com.didww.examples.VoiceInTrunkGroupsExample`](src/main/java/com/didww/examples/VoiceInTrunkGroupsExample.java) | CRUD for trunk groups with trunk relationships; cleans up when a step fails. | | [`com.didww.examples.VoiceInTrunkSipRegistrationExample`](src/main/java/com/didww/examples/VoiceInTrunkSipRegistrationExample.java) | End-to-end SIP registration flow: create with `enabledSipRegistration=true`, rename, disable by setting `host`, re-enable by toggling the flag. The SDK keeps the dependent fields (`host`, `port`, `useDidInRuri`) aligned with the server's validation rules automatically. | -| [`com.didww.examples.VoiceOutTrunksExample`](src/main/java/com/didww/examples/VoiceOutTrunksExample.java) | CRUD for voice out trunks (requires account config). | +| [`com.didww.examples.VoiceOutTrunksExample`](src/main/java/com/didww/examples/VoiceOutTrunksExample.java) | CRUD for voice out trunks, with codecs and ICE mode (requires account config). | | [`com.didww.examples.DidTrunkAssignmentExample`](src/main/java/com/didww/examples/DidTrunkAssignmentExample.java) | Demonstrates exclusive trunk/trunk group assignment on DIDs, then restores the DID's original routing. | | [`com.didww.examples.DidReservationsExample`](src/main/java/com/didww/examples/DidReservationsExample.java) | Creates, lists, finds and deletes DID reservations. | | [`com.didww.examples.ExportsExample`](src/main/java/com/didww/examples/ExportsExample.java) | Creates and lists CDR exports with from/to datetime range filters (UTC). | diff --git a/examples/src/main/java/com/didww/examples/DidTrunkAssignmentExample.java b/examples/src/main/java/com/didww/examples/DidTrunkAssignmentExample.java index 57162ba4..555d85ef 100644 --- a/examples/src/main/java/com/didww/examples/DidTrunkAssignmentExample.java +++ b/examples/src/main/java/com/didww/examples/DidTrunkAssignmentExample.java @@ -63,7 +63,7 @@ public static void main(String[] args) { SipConfiguration sip = new SipConfiguration(); sip.setHost("sip.example.com"); sip.setPort(5060); - sip.setCodecIds(Arrays.asList(Codec.PCMU, Codec.PCMA)); + sip.setCodecs(Arrays.asList(Codec.PCMU, Codec.PCMA)); sip.setTransportProtocolId(TransportProtocol.UDP); trunk.setConfiguration(sip); trunk.setPop(Pop.FRA); // optional; leave it unset to let DIDWW choose diff --git a/examples/src/main/java/com/didww/examples/EmergencyRequirementsExample.java b/examples/src/main/java/com/didww/examples/EmergencyRequirementsExample.java index a71f66cf..36170685 100644 --- a/examples/src/main/java/com/didww/examples/EmergencyRequirementsExample.java +++ b/examples/src/main/java/com/didww/examples/EmergencyRequirementsExample.java @@ -38,9 +38,10 @@ public static void main(String[] args) { System.out.println(" Number type: " + req.getNumberType()); System.out.println(" Identity type required: " + req.getIdentityType()); System.out.println(" Address area level: " + req.getAddressAreaLevel()); - if (req.getAddressMandatoryFields() != null) { - System.out.println(" Address mandatory fields: " + String.join(", ", req.getAddressMandatoryFields())); - } + // Field names as the API spells them, such as "area" or "birth_date"; empty when none are mandatory + System.out.println(" Address mandatory fields: " + req.getAddressMandatoryFields()); + System.out.println(" Personal mandatory fields: " + req.getPersonalMandatoryFields()); + System.out.println(" Business mandatory fields: " + req.getBusinessMandatoryFields()); System.out.println(" Estimated setup time (days): " + req.getEstimateSetupTime()); if (req.getRequirementRestrictionMessage() != null) { System.out.println(" Restriction: " + req.getRequirementRestrictionMessage()); diff --git a/examples/src/main/java/com/didww/examples/ExportsSmsExample.java b/examples/src/main/java/com/didww/examples/ExportsSmsExample.java index cfbcd3db..81f4f7e7 100644 --- a/examples/src/main/java/com/didww/examples/ExportsSmsExample.java +++ b/examples/src/main/java/com/didww/examples/ExportsSmsExample.java @@ -5,6 +5,7 @@ import com.didww.sdk.http.QueryParams; import com.didww.sdk.resource.Export; import com.didww.sdk.resource.enums.ExportStatus; +import com.didww.sdk.resource.enums.ExportServiceType; import com.didww.sdk.resource.enums.ExportTrunkType; import com.didww.sdk.resource.enums.ExportType; @@ -29,10 +30,13 @@ * Filters on SMS exports (Export#setFilters): * - "from", "to" (required): "YYYY-MM-DD HH:MM:SS" in UTC; from inclusive, to exclusive and not in the future * - "source_address_eq" / "source_address_cont", "destination_address_eq" / "destination_address_cont", - * "status", "sms_id" + * "status" (sms_in: "success", "failed", "queued"; sms_out: "routing_error", "in_progress", + * "queued", "failed", "success"), "sms_id" * - "sms_trunk.id": one id or a list of ids - * - sms_in only: "trunk_type", one or a list of ExportTrunkType values in their wire form - * - sms_out only: "service_type" ("a2p" or "p2p"), "sender_id_verification.id" + * - sms_in only: "trunk_type", an ExportTrunkType or a List of them (or of wire strings) + * - sms_out only: "service_type", an ExportServiceType ("a2p" or "p2p"); "sender_id_verification.id" + * The values of "service_type", "trunk_type" and "status" are lowercase; an SDK enum constant is + * sent as its wire value. * * Usage: DIDWW_API_KEY=xxx ./gradlew runExample -PexampleClass=com.didww.examples.ExportsSmsExample */ @@ -51,15 +55,14 @@ public static void main(String[] args) throws IOException { Map outFilters = new HashMap<>(); outFilters.put("from", from); outFilters.put("to", to); - outFilters.put("service_type", "a2p"); + outFilters.put("service_type", ExportServiceType.A2P); create(client, ExportType.SMS_OUT, outFilters, "java-sms-out-" + suffix); - System.out.println("\n=== Creating SMS-In Export (HTTP IN and email trunks, yesterday) ==="); + System.out.println("\n=== Creating SMS-In Export (HTTP IN and SMS to email trunks, yesterday) ==="); Map inFilters = new HashMap<>(); inFilters.put("from", from); inFilters.put("to", to); - inFilters.put("trunk_type", Arrays.asList( - EnumWire.value(ExportTrunkType.HTTP_IN), EnumWire.value(ExportTrunkType.EMAIL))); + inFilters.put("trunk_type", Arrays.asList(ExportTrunkType.HTTP_IN, ExportTrunkType.SMS_TO_EMAIL)); create(client, ExportType.SMS_IN, inFilters, "java-sms-in-" + suffix); System.out.println("\n=== Completed SMS-Out exports ==="); diff --git a/examples/src/main/java/com/didww/examples/IdentityAddressProofsExample.java b/examples/src/main/java/com/didww/examples/IdentityAddressProofsExample.java index 15938c7e..a52d9fc8 100644 --- a/examples/src/main/java/com/didww/examples/IdentityAddressProofsExample.java +++ b/examples/src/main/java/com/didww/examples/IdentityAddressProofsExample.java @@ -74,9 +74,9 @@ public static void main(String[] args) throws Exception { ProofType identityProofType = null; ProofType addressProofType = null; for (ProofType pt : proofTypes) { - if ("Personal".equals(pt.getEntityType()) && identityProofType == null) { + if ("personal".equals(pt.getEntityType()) && identityProofType == null) { identityProofType = pt; - } else if ("Address".equals(pt.getEntityType()) && addressProofType == null) { + } else if ("address".equals(pt.getEntityType()) && addressProofType == null) { addressProofType = pt; } if (identityProofType != null && addressProofType != null) break; diff --git a/examples/src/main/java/com/didww/examples/PricesDownloadExample.java b/examples/src/main/java/com/didww/examples/PricesDownloadExample.java index 08ff1921..d22fa4e7 100644 --- a/examples/src/main/java/com/didww/examples/PricesDownloadExample.java +++ b/examples/src/main/java/com/didww/examples/PricesDownloadExample.java @@ -73,7 +73,7 @@ private static void run(PricesService prices, Path path) throws IO System.out.println("\n=== First rows ==="); try (Stream> rows = PriceFileReader.rows(path)) { rows.limit(FIRST_ROWS).forEach(row -> System.out.println(" " + row.get("Country") + " " - + row.get("Destination") + " " + row.get("Source type") + ": " + row.get("Rate (USD, excl. VAT)"))); + + row.get("Destination") + " " + row.get("Source type") + ": " + row.get("Rate"))); } } diff --git a/examples/src/main/java/com/didww/examples/TrunksExample.java b/examples/src/main/java/com/didww/examples/TrunksExample.java index 7a2e63f5..5318be58 100644 --- a/examples/src/main/java/com/didww/examples/TrunksExample.java +++ b/examples/src/main/java/com/didww/examples/TrunksExample.java @@ -7,6 +7,7 @@ import com.didww.sdk.resource.configuration.SipConfiguration; import com.didww.sdk.resource.enums.CliFormat; import com.didww.sdk.resource.enums.Codec; +import com.didww.sdk.resource.enums.IceMode; import com.didww.sdk.resource.enums.Pop; import com.didww.sdk.resource.enums.ReroutingDisconnectCode; import com.didww.sdk.resource.enums.TransportProtocol; @@ -44,7 +45,9 @@ public static void main(String[] args) { SipConfiguration sip = new SipConfiguration(); sip.setHost("sip.example.com"); sip.setPort(5060); - sip.setCodecIds(Arrays.asList(Codec.PCMU, Codec.PCMA)); + // Codecs by name, in priority order; left out, DIDWW applies its default list + sip.setCodecs(Arrays.asList(Codec.PCMU, Codec.PCMA, Codec.TELEPHONE_EVENT)); + sip.setIceMode(IceMode.ACCEPT); sip.setTransportProtocolId(TransportProtocol.UDP); sip.setReroutingDisconnectCodeIds(Arrays.asList( ReroutingDisconnectCode.SIP_480_TEMPORARILY_UNAVAILABLE, diff --git a/examples/src/main/java/com/didww/examples/VoiceInTrunkGroupsExample.java b/examples/src/main/java/com/didww/examples/VoiceInTrunkGroupsExample.java index 92e4406f..3d9e55c3 100644 --- a/examples/src/main/java/com/didww/examples/VoiceInTrunkGroupsExample.java +++ b/examples/src/main/java/com/didww/examples/VoiceInTrunkGroupsExample.java @@ -87,7 +87,7 @@ private static VoiceInTrunk sipTrunk(String name, String host, Pop pop, List voiceOutTrunks() { return new Repository<>(httpClient, converter, baseUrl, "voice_out_trunks", VoiceOutTrunk.class, objectMapper); } + /** + * Rotates the generated credentials of a Voice OUT trunk. Only {@code create} is supported; a trunk + * with {@code twilio} authentication is answered 404. + * + * @return the repository + */ public Repository voiceOutTrunkRegenerateCredentials() { return new Repository<>(httpClient, converter, baseUrl, "voice_out_trunk_regenerate_credentials", VoiceOutTrunkRegenerateCredential.class, objectMapper); } @@ -320,7 +327,9 @@ public Repository addressRequirementValidations() * @param fingerprint encryption key fingerprint identifying the public key used * @param description optional human-readable description for the file (may be {@code null}) * @return the encrypted file ID returned by the API - * @throws DidwwClientException if the upload fails or the response is unexpected + * @throws DidwwApiException if the API answers with an error status (see + * {@link DidwwApiException#getRetryAfter()}); a 429 is not retried + * @throws DidwwClientException if the upload fails in transport or the response is unexpected */ public String uploadEncryptedFile(byte[] encryptedData, String fileName, @@ -350,10 +359,7 @@ public String uploadEncryptedFile(byte[] encryptedData, try (Response response = httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { - byte[] errorBody = Bodies.readLimitedQuietly(response.body(), Bodies.MAX_ERROR_BODY_SIZE, - Bodies.errorBodyDeadlineMillis(httpClient)); - String detail = errorBody.length > 0 ? " " + new String(errorBody, StandardCharsets.UTF_8) : ""; - throw new DidwwClientException("Failed to upload encrypted file: HTTP " + response.code() + detail); + throw unexpectedStatus(response); } String responseBody = response.body() != null ? response.body().string() : ""; @@ -379,7 +385,8 @@ public String uploadEncryptedFile(byte[] encryptedData, * elsewhere: a URL on any other origin raises {@link DidwwClientException} * without a request. Only a 200 answer is written, as it arrives; redirects * are not followed, and any other status, including 3xx and 204, raises - * {@link DidwwApiException}. + * {@link DidwwApiException} (see {@link DidwwApiException#getRetryAfter()}); + * a 429 is not retried. * * @param url URL of the export file ({@code Export.getUrl()}) * @param destination where the file is written @@ -396,8 +403,8 @@ public void downloadExport(String url, OutputStream destination) { * Downloads an export file to {@code destination} unchanged (gzip-compressed * CSV). The URL is checked and the answer handled as in * {@link #downloadExport(String, OutputStream)}. The file is written to a - * temporary file next to {@code destination}, which replaces it only once - * the whole file has arrived. + * temporary file {@code .didww-<16 hex digits>.tmp} in the directory of + * {@code destination}, which replaces it only once the whole file has arrived. * * @param url URL of the export file ({@code Export.getUrl()}) * @param destination local path where the file will be written @@ -429,9 +436,10 @@ public void downloadAndDecompressExport(String url, OutputStream destination) { /** * Downloads a gzip-compressed export file (.csv.gz) and writes the - * decompressed CSV to {@code destination} through a temporary file next to - * it, which replaces it only once the whole file has been decompressed. The - * URL is checked and the answer handled as in + * decompressed CSV to {@code destination} through a temporary file + * {@code .didww-<16 hex digits>.tmp} in its directory, which replaces it only + * once the whole file has been decompressed. The URL is checked and the + * answer handled as in * {@link #downloadExport(String, OutputStream)}. * * @param url URL of the gzip-compressed export file @@ -554,7 +562,7 @@ private DidwwApiException unexpectedStatus(Response response) { error.setDetail(body.isEmpty() ? null : body); errors = Collections.singletonList(error); } - return new DidwwApiException(response.code(), errors); + return new DidwwApiException(response.code(), errors, RetryAfter.seconds(response)); } private static void validateApiKey(String apiKey) { diff --git a/src/main/java/com/didww/sdk/exception/DidwwApiException.java b/src/main/java/com/didww/sdk/exception/DidwwApiException.java index ee8ce998..b82d6ca2 100644 --- a/src/main/java/com/didww/sdk/exception/DidwwApiException.java +++ b/src/main/java/com/didww/sdk/exception/DidwwApiException.java @@ -2,24 +2,62 @@ import com.fasterxml.jackson.annotation.JsonIgnore; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; +/** + * An error status of the JSON:API repositories, the export downloads or + * {@code uploadEncryptedFile}, with the error objects of the answer. When the + * body is not a JSON:API errors document, the export downloads and the upload + * report one error object holding the raw body, and the repositories give the + * raw body as the message, without error objects. + * + *

A 429 ({@code too_many_requests}) means the request budget of the account + * for the current window is spent. The SDK does not retry it, as a write may + * not be safe to send twice: wait {@link #getRetryAfter()} before sending the + * request again. + */ public class DidwwApiException extends DidwwException { private final int httpStatus; private final transient List errors; + private final Duration retryAfter; public DidwwApiException(int httpStatus, List errors) { + this(httpStatus, errors, null); + } + + /** + * Creates the exception. + * + * @param httpStatus HTTP status of the answer + * @param errors the error objects of the answer, or {@code null} + * @param retryAfter the Retry-After of the answer, when given in seconds, or {@code null} + */ + public DidwwApiException(int httpStatus, List errors, Duration retryAfter) { super(buildMessage(httpStatus, errors)); this.httpStatus = httpStatus; this.errors = errors != null ? Collections.unmodifiableList(errors) : Collections.emptyList(); + this.retryAfter = retryAfter; } public DidwwApiException(int httpStatus, String message) { + this(httpStatus, message, null); + } + + /** + * Creates the exception for an answer without error objects. + * + * @param httpStatus HTTP status of the answer + * @param message the message + * @param retryAfter the Retry-After of the answer, when given in seconds, or {@code null} + */ + public DidwwApiException(int httpStatus, String message, Duration retryAfter) { super(message); this.httpStatus = httpStatus; this.errors = Collections.emptyList(); + this.retryAfter = retryAfter; } public int getHttpStatus() { @@ -30,6 +68,17 @@ public List getErrors() { return errors != null ? errors : Collections.emptyList(); } + /** + * Returns the Retry-After of the answer, when it is given in seconds, for + * any error status; a 429 carries it. + * + * @return the time to wait, or {@code null} when the answer has none or it + * is not a number of seconds + */ + public Duration getRetryAfter() { + return retryAfter; + } + private static String buildMessage(int httpStatus, List errors) { StringBuilder sb = new StringBuilder("DIDWW API error (HTTP " + httpStatus + ")"); if (errors != null && !errors.isEmpty()) { diff --git a/src/main/java/com/didww/sdk/internal/AtomicFile.java b/src/main/java/com/didww/sdk/internal/AtomicFile.java index ed54e8c1..35cdeb81 100644 --- a/src/main/java/com/didww/sdk/internal/AtomicFile.java +++ b/src/main/java/com/didww/sdk/internal/AtomicFile.java @@ -16,7 +16,9 @@ /** * Internal helper, not part of the SDK API: writes a file through a temporary - * file next to it, which replaces the target only on {@link #commit(FileTime)}. + * file {@code .didww-<16 hex digits>.tmp} in its directory, a name that fits + * any file system whatever the length of the file name, which replaces the + * target only on {@link #commit(FileTime)}. * The temporary file is created with the default permissions of new files * (the umask on POSIX systems), so the target gets them too. */ @@ -37,20 +39,19 @@ private AtomicFile(Path target, Path temp, FileChannel channel) { } /** - * Creates a new temporary file next to {@code target}. + * Creates a new temporary file in the directory of {@code target}. * * @param target the file to write * @return the open file * @throws IOException if the temporary file cannot be created */ public static AtomicFile open(Path target) throws IOException { - Path name = target.getFileName(); - if (name == null) { + if (target.getFileName() == null) { throw new IOException("Not a file path: " + target); } FileAlreadyExistsException last = null; for (int i = 0; i < MAX_NAME_ATTEMPTS; i++) { - Path temp = target.resolveSibling("." + name + "." + Jitter.randomHex(8) + ".tmp"); + Path temp = target.resolveSibling(".didww-" + Jitter.randomHex(8) + ".tmp"); try { FileChannel channel = FileChannel.open(temp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); return new AtomicFile(target, temp, channel); diff --git a/src/main/java/com/didww/sdk/internal/RetryAfter.java b/src/main/java/com/didww/sdk/internal/RetryAfter.java new file mode 100644 index 00000000..44848dfe --- /dev/null +++ b/src/main/java/com/didww/sdk/internal/RetryAfter.java @@ -0,0 +1,31 @@ +package com.didww.sdk.internal; + +import okhttp3.Response; + +import java.time.Duration; + +/** + * Internal helper, not part of the SDK API: reads the {@code Retry-After} of + * an answer. + */ +public final class RetryAfter { + + private RetryAfter() { + } + + /** + * Returns the {@code Retry-After} of {@code response} when it is a whole + * number of seconds; an HTTP date or any other value reads as {@code null}. + * + * @param response the answer + * @return the time to wait, or {@code null} + */ + public static Duration seconds(Response response) { + String header = response.header("Retry-After"); + if (header == null) { + return null; + } + String value = header.trim(); + return value.matches("\\d{1,9}") ? Duration.ofSeconds(Long.parseLong(value)) : null; + } +} diff --git a/src/main/java/com/didww/sdk/prices/PriceFileReader.java b/src/main/java/com/didww/sdk/prices/PriceFileReader.java index 9a4c7d4f..ec62958a 100644 --- a/src/main/java/com/didww/sdk/prices/PriceFileReader.java +++ b/src/main/java/com/didww/sdk/prices/PriceFileReader.java @@ -27,7 +27,7 @@ * Reads a price file as downloaded (gzipped CSV), one row at a time. * *

Each row is a map keyed by the header line as is (for example - * {@code Rate (USD, excl. VAT)}), in header order; the values are the CSV + * {@code Rate} or {@code Connection fee}), in header order; the values are the CSV * fields as is, empty for a missing value. A UTF-8 byte order mark is skipped, * invalid UTF-8 reads as U+FFFD, empty lines are skipped, and a file with only * a header has no rows. diff --git a/src/main/java/com/didww/sdk/prices/PricesService.java b/src/main/java/com/didww/sdk/prices/PricesService.java index 2e66bc51..486e8f18 100644 --- a/src/main/java/com/didww/sdk/prices/PricesService.java +++ b/src/main/java/com/didww/sdk/prices/PricesService.java @@ -9,6 +9,7 @@ import com.didww.sdk.internal.AtomicFile; import com.didww.sdk.internal.Bodies; import com.didww.sdk.internal.Jitter; +import com.didww.sdk.internal.RetryAfter; import com.didww.sdk.internal.Sleeper; import com.didww.sdk.internal.Streams; import com.didww.sdk.internal.Urls; @@ -222,7 +223,8 @@ public PriceFile download(OutputStream destination, Instant ifModifiedSince) { /** * Downloads the price file to {@code path}. The file is written to a - * temporary file next to {@code path}, flushed to the disk and moved over + * temporary file {@code .didww-<16 hex digits>.tmp} in the directory of + * {@code path}, flushed to the disk and moved over * {@code path} only when the whole file has arrived, so a failed download * leaves an existing file as it was. The new file gets the default * permissions of a new file (the umask on POSIX systems) and the @@ -329,7 +331,7 @@ private Duration unavailableWait(Response response, int attempt) { if (response.code() != 503 || attempt > MAX_UNAVAILABLE_RETRIES) { return null; } - Duration retryAfter = retryAfter(response); + Duration retryAfter = RetryAfter.seconds(response); if (retryAfter == null || retryAfter.getSeconds() > MAX_UNAVAILABLE_RETRY_AFTER_SECONDS) { return null; } @@ -430,17 +432,7 @@ private DidwwPricesException error(Response response) { kind = PricesErrorKind.UNEXPECTED_STATUS; break; } - return new DidwwPricesException(kind, status, code, errors, retryAfter(response), null); - } - - // The Retry-After of an answer when given in seconds; an HTTP date is ignored. - private static Duration retryAfter(Response response) { - String header = response.header("Retry-After"); - if (header == null) { - return null; - } - String value = header.trim(); - return value.matches("\\d{1,9}") ? Duration.ofSeconds(Long.parseLong(value)) : null; + return new DidwwPricesException(kind, status, code, errors, RetryAfter.seconds(response), null); } // The error objects of a JSON:API errors document; none for any other body. diff --git a/src/main/java/com/didww/sdk/repository/ReadOnlyRepository.java b/src/main/java/com/didww/sdk/repository/ReadOnlyRepository.java index 4d67a696..88b36182 100644 --- a/src/main/java/com/didww/sdk/repository/ReadOnlyRepository.java +++ b/src/main/java/com/didww/sdk/repository/ReadOnlyRepository.java @@ -4,6 +4,7 @@ import com.didww.sdk.exception.DidwwClientException; import com.didww.sdk.http.QueryParams; import com.didww.sdk.internal.Bodies; +import com.didww.sdk.internal.RetryAfter; import com.didww.sdk.resource.BaseResource; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -18,6 +19,7 @@ import java.io.IOException; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.IdentityHashMap; @@ -88,7 +90,8 @@ public ApiResponse find(String id, QueryParams params) { /** * Throws {@link DidwwApiException} when the response status is not successful, - * parsing API error objects from the response body when available. At most + * parsing API error objects from the response body when available, with the + * Retry-After of the answer; a 429 is not retried. At most * 1 MiB of the body is read, within the read timeout of the client (or 60 * seconds); a body that breaks or stalls keeps the status. * @@ -113,10 +116,11 @@ protected void handleErrorResponse(Response response) throws IOException { } } catch (Exception ignored) { } + Duration retryAfter = RetryAfter.seconds(response); if (errors.isEmpty()) { - throw new DidwwApiException(response.code(), body); + throw new DidwwApiException(response.code(), body, retryAfter); } - throw new DidwwApiException(response.code(), errors); + throw new DidwwApiException(response.code(), errors, retryAfter); } } diff --git a/src/main/java/com/didww/sdk/resource/AddressRequirement.java b/src/main/java/com/didww/sdk/resource/AddressRequirement.java index a434bfe8..ddfec922 100644 --- a/src/main/java/com/didww/sdk/resource/AddressRequirement.java +++ b/src/main/java/com/didww/sdk/resource/AddressRequirement.java @@ -36,9 +36,19 @@ public class AddressRequirement extends BaseResource { @JsonProperty("address_proof_qty") private Integer addressProofQty; + /** + * Identity fields that must be filled in for a personal identity, among {@code birth_date}, + * {@code country}, {@code birth_country}, {@code id_number}, {@code personal_tax_id} and + * {@code contact_email}. Empty when none are. + */ @JsonProperty("personal_mandatory_fields") private List personalMandatoryFields; + /** + * Identity fields that must be filled in for a business identity, among {@code id_number}, + * {@code tax_id} (the {@code vat_id} of an {@link Identity}), {@code country}, {@code company_reg_number}, + * {@code personal_tax_id} and {@code contact_email}. Empty when none are. + */ @JsonProperty("business_mandatory_fields") private List businessMandatoryFields; diff --git a/src/main/java/com/didww/sdk/resource/EmergencyRequirement.java b/src/main/java/com/didww/sdk/resource/EmergencyRequirement.java index ca0d486b..f5118dd0 100644 --- a/src/main/java/com/didww/sdk/resource/EmergencyRequirement.java +++ b/src/main/java/com/didww/sdk/resource/EmergencyRequirement.java @@ -35,12 +35,22 @@ public class EmergencyRequirement extends MetaResource { @JsonProperty("business_area_level") private String businessAreaLevel; + /** Address fields that must be filled in ({@code area}). Empty when none are. */ @JsonProperty("address_mandatory_fields") private List addressMandatoryFields; + /** + * Fields that must be filled in for a personal identity, among {@code birth_date}, {@code country}, + * {@code id_number}, {@code personal_tax_id} and {@code contact_email}. Empty when none are. + */ @JsonProperty("personal_mandatory_fields") private List personalMandatoryFields; + /** + * Fields that must be filled in for a business identity, among {@code id_number}, {@code tax_id} + * (the {@code vat_id} of an {@link Identity}), {@code country}, {@code company_reg_number}, {@code personal_tax_id}, + * {@code contact_email} and {@code birth_date}. Empty when none are. + */ @JsonProperty("business_mandatory_fields") private List businessMandatoryFields; diff --git a/src/main/java/com/didww/sdk/resource/Export.java b/src/main/java/com/didww/sdk/resource/Export.java index 7a2f95c5..154855f3 100644 --- a/src/main/java/com/didww/sdk/resource/Export.java +++ b/src/main/java/com/didww/sdk/resource/Export.java @@ -54,6 +54,8 @@ public void setCallbackMethod(CallbackMethod callbackMethod) { /** * Export filters. Dates are {@code "YYYY-MM-DD HH:MM:SS"} in UTC; list values are sent as JSON arrays. + * An SDK enum constant is sent as its wire value, such as {@code a2p} for + * {@link com.didww.sdk.resource.enums.ExportServiceType#A2P}; {@code name()} is not the wire form. *

    *
  • Every export: {@code from} (inclusive) and {@code to} (exclusive).
  • *
  • {@code cdr_in}: {@code did_number}, {@code source_number}, {@code source_number_out}, @@ -61,10 +63,14 @@ public void setCallbackMethod(CallbackMethod callbackMethod) { *
  • {@code cdr_out}: {@code voice_out_trunk.id}, {@code source}, {@code cli}, {@code destination}.
  • *
  • {@code sms_in} and {@code sms_out}: {@code source_address_eq}, {@code source_address_cont}, * {@code destination_address_eq}, {@code destination_address_cont}, {@code status}, {@code sms_id}, - * {@code sms_trunk.id} (a string or a list).
  • - *
  • {@code sms_in} only: {@code trunk_type} (a string or a list, see - * {@link com.didww.sdk.resource.enums.ExportTrunkType}).
  • - *
  • {@code sms_out} only: {@code service_type} ({@code a2p} or {@code p2p}) and + * {@code sms_trunk.id} (a string or a list); {@code status} is {@code success}, {@code failed} or + * {@code queued} for {@code sms_in}, and {@code routing_error}, {@code in_progress}, {@code queued}, + * {@code failed} or {@code success} for {@code sms_out}.
  • + *
  • {@code sms_in} only: {@code trunk_type}, an {@link com.didww.sdk.resource.enums.ExportTrunkType}, + * a list of them, or the wire string ({@code smpp_smsc}, {@code smpp_esme}, {@code sms_to_email}, + * {@code http_in}).
  • + *
  • {@code sms_out} only: {@code service_type}, {@code a2p} or {@code p2p} (see + * {@link com.didww.sdk.resource.enums.ExportServiceType}), and * {@code sender_id_verification.id} (a string or a list).
  • *
  • Read back only: {@code year}, {@code month} and {@code day}, on exports created at earlier API versions.
  • *
diff --git a/src/main/java/com/didww/sdk/resource/Identity.java b/src/main/java/com/didww/sdk/resource/Identity.java index 9d40eb8f..e1b669e3 100644 --- a/src/main/java/com/didww/sdk/resource/Identity.java +++ b/src/main/java/com/didww/sdk/resource/Identity.java @@ -39,6 +39,10 @@ public class Identity extends BaseResource implements ProofEntity { @JsonProperty("company_reg_number") private String companyRegNumber; + /** + * VAT identification number, listed as {@code tax_id} in the mandatory fields of + * {@link AddressRequirement} and {@link EmergencyRequirement}. + */ @JsonProperty("vat_id") private String vatId; diff --git a/src/main/java/com/didww/sdk/resource/ProofType.java b/src/main/java/com/didww/sdk/resource/ProofType.java index 6f711475..8c906f5c 100644 --- a/src/main/java/com/didww/sdk/resource/ProofType.java +++ b/src/main/java/com/didww/sdk/resource/ProofType.java @@ -12,6 +12,7 @@ public class ProofType extends BaseResource { @JsonProperty("name") private String name; + /** What the document proves: {@code address}, {@code business} or {@code personal}. */ @JsonProperty("entity_type") private String entityType; } diff --git a/src/main/java/com/didww/sdk/resource/VoiceOutTrunk.java b/src/main/java/com/didww/sdk/resource/VoiceOutTrunk.java index 0151b2dd..551d301d 100644 --- a/src/main/java/com/didww/sdk/resource/VoiceOutTrunk.java +++ b/src/main/java/com/didww/sdk/resource/VoiceOutTrunk.java @@ -4,8 +4,12 @@ import com.didww.sdk.converter.AuthenticationMethodSerializer; import com.didww.sdk.resource.authenticationmethod.AuthenticationMethod; import com.didww.sdk.resource.enums.DefaultDstAction; +import com.didww.sdk.resource.enums.IceMode; import com.didww.sdk.resource.enums.MediaEncryptionMode; import com.didww.sdk.resource.enums.OnCliMismatchAction; +import com.didww.sdk.resource.enums.RtcpFeedbackMode; +import com.didww.sdk.resource.enums.RtcpMuxMode; +import com.didww.sdk.resource.enums.VoiceOutCodec; import com.didww.sdk.resource.enums.VoiceOutTrunkStatus; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; @@ -53,6 +57,26 @@ public class VoiceOutTrunk extends BaseResource { @JsonProperty("dst_prefixes") private List dstPrefixes; + /** + * Codecs offered on calls from the trunk, in priority order: the first has the highest priority. + * Left out on create, the trunk offers {@code pcma}, {@code pcmu} and {@code telephone_event}. + * (API 2026-10-22) + */ + @JsonProperty("codecs") + private List codecs; + + /** ICE on calls from the trunk. Defaults to {@link IceMode#ACCEPT}. (API 2026-10-22) */ + @JsonProperty("ice_mode") + private IceMode iceMode; + + /** RTP and RTCP on one port on calls from the trunk. Defaults to {@link RtcpMuxMode#ACCEPT}. (API 2026-10-22) */ + @JsonProperty("rtcp_mux_mode") + private RtcpMuxMode rtcpMuxMode; + + /** RTCP feedback (AVPF) on calls from the trunk. Defaults to {@link RtcpFeedbackMode#ACCEPT}. (API 2026-10-22) */ + @JsonProperty("rtcp_feedback_mode") + private RtcpFeedbackMode rtcpFeedbackMode; + @JsonProperty("force_symmetric_rtp") private Boolean forceSymmetricRtp; @@ -127,6 +151,43 @@ public void setDstPrefixes(List dstPrefixes) { this.dstPrefixes = markDirty("dstPrefixes", dstPrefixes); } + /** + * Sets the codecs offered on calls from the trunk, in priority order: the first has the highest + * priority. The names differ from those of an inbound SIP trunk ({@code Codec}). + * + * @param codecs the codecs, at least one and each once + */ + public void setCodecs(List codecs) { + this.codecs = markDirty("codecs", codecs); + } + + /** + * Sets ICE on calls from the trunk. + * + * @param iceMode the mode + */ + public void setIceMode(IceMode iceMode) { + this.iceMode = markDirty("iceMode", iceMode); + } + + /** + * Sets RTP and RTCP on one port on calls from the trunk. + * + * @param rtcpMuxMode the mode + */ + public void setRtcpMuxMode(RtcpMuxMode rtcpMuxMode) { + this.rtcpMuxMode = markDirty("rtcpMuxMode", rtcpMuxMode); + } + + /** + * Sets RTCP feedback (AVPF) on calls from the trunk. + * + * @param rtcpFeedbackMode the mode + */ + public void setRtcpFeedbackMode(RtcpFeedbackMode rtcpFeedbackMode) { + this.rtcpFeedbackMode = markDirty("rtcpFeedbackMode", rtcpFeedbackMode); + } + public void setForceSymmetricRtp(Boolean forceSymmetricRtp) { this.forceSymmetricRtp = markDirty("forceSymmetricRtp", forceSymmetricRtp); } diff --git a/src/main/java/com/didww/sdk/resource/configuration/SipConfiguration.java b/src/main/java/com/didww/sdk/resource/configuration/SipConfiguration.java index 1d66fcad..3c04a492 100644 --- a/src/main/java/com/didww/sdk/resource/configuration/SipConfiguration.java +++ b/src/main/java/com/didww/sdk/resource/configuration/SipConfiguration.java @@ -35,8 +35,25 @@ public class SipConfiguration extends TrunkConfiguration { @JsonInclude(JsonInclude.Include.ALWAYS) private Integer port; - @JsonProperty("codec_ids") - private List codecIds; + /** + * Codecs offered on calls to the trunk, in priority order: the first has the highest priority. + * Left out on create, the trunk offers {@code pcmu}, {@code pcma}, {@code g729}, {@code g723} + * and {@code telephone_event}. (API 2026-10-22: replaces {@code codec_ids}) + */ + @JsonProperty("codecs") + private List codecs; + + /** ICE on calls to the trunk. Defaults to {@link IceMode#DISABLED}. (API 2026-10-22) */ + @JsonProperty("ice_mode") + private IceMode iceMode; + + /** RTP and RTCP on one port on calls to the trunk. Defaults to {@link RtcpMuxMode#DISABLED}. (API 2026-10-22) */ + @JsonProperty("rtcp_mux_mode") + private RtcpMuxMode rtcpMuxMode; + + /** RTCP feedback (AVPF) on calls to the trunk. Defaults to {@link RtcpFeedbackMode#DISABLED}. (API 2026-10-22) */ + @JsonProperty("rtcp_feedback_mode") + private RtcpFeedbackMode rtcpFeedbackMode; @JsonProperty("rx_dtmf_format_id") private RxDtmfFormat rxDtmfFormatId; diff --git a/src/main/java/com/didww/sdk/resource/enums/Codec.java b/src/main/java/com/didww/sdk/resource/enums/Codec.java index b89d9610..9a30f3b1 100644 --- a/src/main/java/com/didww/sdk/resource/enums/Codec.java +++ b/src/main/java/com/didww/sdk/resource/enums/Codec.java @@ -1,19 +1,25 @@ package com.didww.sdk.resource.enums; -public enum Codec implements IntEnum { - TELEPHONE_EVENT(6), - G723(7), - G729(8), - PCMU(9), - PCMA(10), - SPEEX(12), - GSM(13), - G726_32(14), - G721(15), - G726_24(16), - G726_40(17), - G726_16(18), - L16(19); +import com.fasterxml.jackson.annotation.JsonProperty; - Codec(int value) { IntEnum.register(this, value); } +/** + * Codec of an inbound SIP trunk ({@code SipConfiguration#setCodecs}). A Voice OUT trunk has its own + * list, {@link VoiceOutCodec}. (API 2026-10-22) + */ +public enum Codec { + @JsonProperty("telephone_event") TELEPHONE_EVENT, + @JsonProperty("g723") G723, + @JsonProperty("g729") G729, + @JsonProperty("pcmu") PCMU, + @JsonProperty("pcma") PCMA, + @JsonProperty("speex") SPEEX, + @JsonProperty("gsm") GSM, + @JsonProperty("g726_32") G726_32, + @JsonProperty("g721") G721, + @JsonProperty("g726_24") G726_24, + @JsonProperty("g726_40") G726_40, + @JsonProperty("g726_16") G726_16, + @JsonProperty("l16") L16, + @JsonProperty("amr") AMR, + @JsonProperty("amr_wb") AMR_WB; } diff --git a/src/main/java/com/didww/sdk/resource/enums/ExportServiceType.java b/src/main/java/com/didww/sdk/resource/enums/ExportServiceType.java new file mode 100644 index 00000000..8bf51e8b --- /dev/null +++ b/src/main/java/com/didww/sdk/resource/enums/ExportServiceType.java @@ -0,0 +1,9 @@ +package com.didww.sdk.resource.enums; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Values of the {@code service_type} filter of an {@code sms_out} export. (API 2026-10-22) */ +public enum ExportServiceType { + @JsonProperty("a2p") A2P, + @JsonProperty("p2p") P2P; +} diff --git a/src/main/java/com/didww/sdk/resource/enums/ExportTrunkType.java b/src/main/java/com/didww/sdk/resource/enums/ExportTrunkType.java index 4ed5a18a..68236f79 100644 --- a/src/main/java/com/didww/sdk/resource/enums/ExportTrunkType.java +++ b/src/main/java/com/didww/sdk/resource/enums/ExportTrunkType.java @@ -4,8 +4,8 @@ /** Values of the {@code trunk_type} filter of an {@code sms_in} export. */ public enum ExportTrunkType { - @JsonProperty("SMPP SMSC") SMPP_SMSC, - @JsonProperty("SMPP ESME") SMPP_ESME, - @JsonProperty("Email") EMAIL, - @JsonProperty("HTTP IN") HTTP_IN; + @JsonProperty("smpp_smsc") SMPP_SMSC, + @JsonProperty("smpp_esme") SMPP_ESME, + @JsonProperty("sms_to_email") SMS_TO_EMAIL, + @JsonProperty("http_in") HTTP_IN; } diff --git a/src/main/java/com/didww/sdk/resource/enums/IceMode.java b/src/main/java/com/didww/sdk/resource/enums/IceMode.java new file mode 100644 index 00000000..84b61673 --- /dev/null +++ b/src/main/java/com/didww/sdk/resource/enums/IceMode.java @@ -0,0 +1,13 @@ +package com.didww.sdk.resource.enums; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * ICE (Interactive Connectivity Establishment) on the calls of a trunk: not used, accepted when the + * other side offers it, or initiated by DIDWW. (API 2026-10-22) + */ +public enum IceMode { + @JsonProperty("disabled") DISABLED, + @JsonProperty("accept") ACCEPT, + @JsonProperty("initiate") INITIATE; +} diff --git a/src/main/java/com/didww/sdk/resource/enums/Pop.java b/src/main/java/com/didww/sdk/resource/enums/Pop.java index e3d4d218..416a387a 100644 --- a/src/main/java/com/didww/sdk/resource/enums/Pop.java +++ b/src/main/java/com/didww/sdk/resource/enums/Pop.java @@ -4,11 +4,11 @@ /** DIDWW point of presence a voice IN trunk is served from (API 2026-10-22). */ public enum Pop { - @JsonProperty("NYC") NYC, - @JsonProperty("FRA") FRA, - @JsonProperty("LAC") LAC, - @JsonProperty("MIA") MIA, - @JsonProperty("SG") SG, - @JsonProperty("HK") HK, - @JsonProperty("AMS") AMS; + @JsonProperty("nyc") NYC, + @JsonProperty("fra") FRA, + @JsonProperty("lac") LAC, + @JsonProperty("mia") MIA, + @JsonProperty("sg") SG, + @JsonProperty("hk") HK, + @JsonProperty("ams") AMS; } diff --git a/src/main/java/com/didww/sdk/resource/enums/RtcpFeedbackMode.java b/src/main/java/com/didww/sdk/resource/enums/RtcpFeedbackMode.java new file mode 100644 index 00000000..33e1472f --- /dev/null +++ b/src/main/java/com/didww/sdk/resource/enums/RtcpFeedbackMode.java @@ -0,0 +1,13 @@ +package com.didww.sdk.resource.enums; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * RTCP feedback (AVPF) on the calls of a trunk: not used, accepted when the other side offers it, + * or initiated by DIDWW. (API 2026-10-22) + */ +public enum RtcpFeedbackMode { + @JsonProperty("disabled") DISABLED, + @JsonProperty("accept") ACCEPT, + @JsonProperty("initiate") INITIATE; +} diff --git a/src/main/java/com/didww/sdk/resource/enums/RtcpMuxMode.java b/src/main/java/com/didww/sdk/resource/enums/RtcpMuxMode.java new file mode 100644 index 00000000..30339096 --- /dev/null +++ b/src/main/java/com/didww/sdk/resource/enums/RtcpMuxMode.java @@ -0,0 +1,13 @@ +package com.didww.sdk.resource.enums; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * RTP and RTCP multiplexed on one port on the calls of a trunk: not used, accepted when the other + * side offers it, or initiated by DIDWW. (API 2026-10-22) + */ +public enum RtcpMuxMode { + @JsonProperty("disabled") DISABLED, + @JsonProperty("accept") ACCEPT, + @JsonProperty("initiate") INITIATE; +} diff --git a/src/main/java/com/didww/sdk/resource/enums/VoiceOutCodec.java b/src/main/java/com/didww/sdk/resource/enums/VoiceOutCodec.java new file mode 100644 index 00000000..b889e416 --- /dev/null +++ b/src/main/java/com/didww/sdk/resource/enums/VoiceOutCodec.java @@ -0,0 +1,20 @@ +package com.didww.sdk.resource.enums; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Codec of a Voice OUT trunk ({@code VoiceOutTrunk#setCodecs}). An inbound SIP trunk has its own + * list, {@link Codec}. (API 2026-10-22) + */ +public enum VoiceOutCodec { + @JsonProperty("telephone_event") TELEPHONE_EVENT, + @JsonProperty("g723") G723, + @JsonProperty("g729") G729, + @JsonProperty("pcmu") PCMU, + @JsonProperty("pcma") PCMA, + @JsonProperty("gsm") GSM, + @JsonProperty("g722") G722, + @JsonProperty("opus") OPUS, + @JsonProperty("amr") AMR, + @JsonProperty("amr_wb") AMR_WB; +} diff --git a/src/test/java/com/didww/sdk/EncryptedFileUploadTest.java b/src/test/java/com/didww/sdk/EncryptedFileUploadTest.java index bab77cd6..8cc0f1a4 100644 --- a/src/test/java/com/didww/sdk/EncryptedFileUploadTest.java +++ b/src/test/java/com/didww/sdk/EncryptedFileUploadTest.java @@ -1,5 +1,6 @@ package com.didww.sdk; +import com.didww.sdk.exception.DidwwApiException; import com.didww.sdk.exception.DidwwClientException; import com.github.tomakehurst.wiremock.stubbing.Scenario; import okhttp3.OkHttpClient; @@ -142,8 +143,8 @@ void testUploadErrorBodyIsLimited() { assertThatThrownBy(() -> client.uploadEncryptedFile( "example".getBytes(StandardCharsets.UTF_8), "sample.pdf.enc", "fingerprint-123", null)) - .isInstanceOf(DidwwClientException.class) - .hasMessageStartingWith("Failed to upload encrypted file: HTTP 422 {\"errors\"") + .isInstanceOfSatisfying(DidwwApiException.class, e -> assertThat(e.getHttpStatus()).isEqualTo(422)) + .hasMessageStartingWith("DIDWW API error (HTTP 422): {\"errors\"") .satisfies(e -> assertThat(e.getMessage().length()).isLessThanOrEqualTo((1 << 20) + 100)); } } diff --git a/src/test/java/com/didww/sdk/ExportDownloadTest.java b/src/test/java/com/didww/sdk/ExportDownloadTest.java index 6c7a94e7..8667b1c9 100644 --- a/src/test/java/com/didww/sdk/ExportDownloadTest.java +++ b/src/test/java/com/didww/sdk/ExportDownloadTest.java @@ -3,9 +3,11 @@ import com.didww.sdk.exception.DidwwApiException; import com.didww.sdk.exception.DidwwClientException; import com.didww.sdk.support.InterruptingBody; +import com.didww.sdk.support.ObservedBody; import com.didww.sdk.support.RawHttpServer; import com.github.tomakehurst.wiremock.stubbing.Scenario; import okhttp3.OkHttpClient; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; @@ -23,6 +25,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.time.Duration; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -296,6 +299,40 @@ void testUnwritableDestinationThrowsUncheckedIOException(boolean decompress) thr assertThat(files()).isEmpty(); } + // The temporary file name does not grow with the destination name, so a + // name close to the 255-byte limit of most file systems still downloads. + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testPathDestinationWithLongName(boolean decompress) throws IOException { + stubExport(gzip(CSV)); + String name = "e".repeat(243) + ".csv.gz"; + List> during = new ArrayList<>(); + DidwwClient observed = clientBuilder() + .httpClientBuilder(new OkHttpClient.Builder().addInterceptor(ObservedBody.interceptor(PATH, + () -> during.add(uncheckedFiles())))) + .build(); + + if (decompress) { + observed.downloadAndDecompressExport(url(), dir.resolve(name)); + } else { + observed.downloadExport(url(), dir.resolve(name)); + } + + assertThat(during).hasSize(1); + assertThat(during.get(0)).singleElement(InstanceOfAssertFactories.STRING) + .matches("\\.didww-[0-9a-f]{16}\\.tmp"); + assertThat(files()).containsExactly(name); + assertThat(Files.size(dir.resolve(name))).isPositive(); + } + + private List uncheckedFiles() { + try { + return files(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + @Test void testTruncatedBodyKeepsTheExistingFile() throws IOException { byte[] answer = ("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: 1000\r\n\r\n" diff --git a/src/test/java/com/didww/sdk/TooManyRequestsTest.java b/src/test/java/com/didww/sdk/TooManyRequestsTest.java new file mode 100644 index 00000000..d97eef88 --- /dev/null +++ b/src/test/java/com/didww/sdk/TooManyRequestsTest.java @@ -0,0 +1,168 @@ +package com.didww.sdk; + +import com.didww.sdk.exception.DidwwApiException; +import com.didww.sdk.resource.Did; +import com.didww.sdk.resource.VoiceInTrunkGroup; +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +/** + * A 429 of the platform API, and the Retry-After of any error status, on every + * path that can receive one: the JSON:API reads and writes, the export + * downloads and the encrypted file upload. + */ +class TooManyRequestsTest extends BaseTest { + private static final String TOO_MANY_REQUESTS_BODY = "{\"errors\":[{\"title\":\"Too Many Requests\"," + + "\"detail\":\"The request budget of the account is spent\",\"code\":\"too_many_requests\",\"status\":\"429\"}]}"; + + @TempDir + Path dir; + + @FunctionalInterface + interface Operation { + void run(DidwwClient client, String origin, Path dir); + } + + static Stream> operations() { + return Stream.of( + Named.of("GET a JSON:API list", (c, o, d) -> c.dids().list()), + Named.of("POST a JSON:API resource", (c, o, d) -> { + VoiceInTrunkGroup group = new VoiceInTrunkGroup(); + group.setName("group"); + c.voiceInTrunkGroups().create(group); + }), + Named.of("PATCH a JSON:API resource", (c, o, d) -> { + Did did = new Did().withId("did-id"); + did.setDescription("updated"); + c.dids().update(did); + }), + Named.of("DELETE a JSON:API resource", (c, o, d) -> c.voiceInTrunkGroups().delete("group-id")), + Named.of("download an export", (c, o, d) -> + c.downloadExport(o + "/v3/exports/e.csv.gz", OutputStream.nullOutputStream())), + Named.of("download and decompress an export to a path", (c, o, d) -> + c.downloadAndDecompressExport(o + "/v3/exports/e.csv.gz", d.resolve("export.csv"))), + Named.of("upload an encrypted file", (c, o, d) -> + c.uploadEncryptedFile("x".getBytes(StandardCharsets.UTF_8), "f.enc", "fingerprint", null))); + } + + static Stream invalidRetryAfter() { + List values = Arrays.asList("1.5", "Wed, 21 Oct 2026 07:28:00 GMT", "-1", "", "1000000000", + "99999999999999999999"); + return operations().flatMap(operation -> values.stream().map(value -> Arguments.of(operation, value))); + } + + private void answerEveryApiCall(ResponseDefinitionBuilder response) { + wireMock.stubFor(any(urlPathMatching("/v3/.*")).willReturn(response)); + } + + private DidwwApiException failure(Operation operation) { + DidwwApiException e = catchThrowableOfType(() -> operation.run(client, origin(), dir), DidwwApiException.class); + assertThat(e).as("a DidwwApiException").isNotNull(); + return e; + } + + private int apiRequests() { + return wireMock.findAll(anyRequestedFor(urlPathMatching("/v3/.*"))).size(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("operations") + void testTooManyRequestsCarriesRetryAfterAndIsNotRetried(Operation operation) { + answerEveryApiCall(aResponse().withStatus(429) + .withHeader("Content-Type", "application/vnd.api+json") + .withHeader("Retry-After", "30") + .withBody(TOO_MANY_REQUESTS_BODY)); + + DidwwApiException e = failure(operation); + + assertThat(e.getHttpStatus()).isEqualTo(429); + assertThat(e.getRetryAfter()).isEqualTo(Duration.ofSeconds(30)); + assertThat(e.getErrors()).extracting(DidwwApiException.ApiError::getCode).containsExactly("too_many_requests"); + assertThat(apiRequests()).as("a 429 is not retried").isEqualTo(1); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("operations") + void testTooManyRequestsWithoutAnErrorsDocument(Operation operation) { + answerEveryApiCall(aResponse().withStatus(429) + .withHeader("Content-Type", "text/plain") + .withHeader("Retry-After", "2") + .withBody("Too Many Requests")); + + DidwwApiException e = failure(operation); + + assertThat(e.getHttpStatus()).isEqualTo(429); + assertThat(e.getRetryAfter()).isEqualTo(Duration.ofSeconds(2)); + assertThat(apiRequests()).isEqualTo(1); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("operations") + void testRetryAfterOfAnotherErrorStatus(Operation operation) { + answerEveryApiCall(aResponse().withStatus(503) + .withHeader("Content-Type", "application/vnd.api+json") + .withHeader("Retry-After", "7") + .withBody("{\"errors\":[{\"title\":\"Service Unavailable\",\"status\":\"503\"}]}")); + + DidwwApiException e = failure(operation); + + assertThat(e.getHttpStatus()).isEqualTo(503); + assertThat(e.getRetryAfter()).isEqualTo(Duration.ofSeconds(7)); + } + + @ParameterizedTest(name = "{0}: Retry-After \"{1}\"") + @MethodSource("invalidRetryAfter") + void testRetryAfterThatIsNotSecondsReadsAsNull(Operation operation, String retryAfter) { + answerEveryApiCall(aResponse().withStatus(429) + .withHeader("Content-Type", "application/vnd.api+json") + .withHeader("Retry-After", retryAfter) + .withBody(TOO_MANY_REQUESTS_BODY)); + + DidwwApiException e = failure(operation); + + assertThat(e.getHttpStatus()).isEqualTo(429); + assertThat(e.getRetryAfter()).isNull(); + } + + @Test + void testLargestRetryAfterIsRead() { + answerEveryApiCall(aResponse().withStatus(429) + .withHeader("Content-Type", "application/vnd.api+json") + .withHeader("Retry-After", "999999999") + .withBody(TOO_MANY_REQUESTS_BODY)); + + DidwwApiException e = failure((c, o, d) -> c.dids().list()); + + assertThat(e.getRetryAfter()).isEqualTo(Duration.ofSeconds(999999999)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("operations") + void testErrorWithoutRetryAfter(Operation operation) { + answerEveryApiCall(aResponse().withStatus(422) + .withHeader("Content-Type", "application/vnd.api+json") + .withBody("{\"errors\":[{\"title\":\"is invalid\",\"status\":\"422\"}]}")); + + DidwwApiException e = failure(operation); + + assertThat(e.getHttpStatus()).isEqualTo(422); + assertThat(e.getRetryAfter()).isNull(); + } +} diff --git a/src/test/java/com/didww/sdk/prices/PriceFileReaderTest.java b/src/test/java/com/didww/sdk/prices/PriceFileReaderTest.java index 32832627..8b4e309a 100644 --- a/src/test/java/com/didww/sdk/prices/PriceFileReaderTest.java +++ b/src/test/java/com/didww/sdk/prices/PriceFileReaderTest.java @@ -93,8 +93,8 @@ void testRowsVoiceIn() { "Src country name", "", "Source type", "cellular", "Destination", "44800", - "Rate (USD, excl. VAT)", "", - "Connection fee (USD, excl. VAT)", "", + "Rate", "", + "Connection fee", "", "Initial interval", "60", "Next interval", "60", "Reject calls", "true")); @@ -113,8 +113,8 @@ void testRowsVoiceOut() { "Prefix", "4930", "Rate", "0.0045", "Connection fee", "0.0", - "Initial Increment", "1", - "Recurring Increment", "1", + "Initial increment", "1", + "Recurring increment", "1", "Reject calls", "false", "Next rate", "0.005", "Next connection fee", "0.0", @@ -123,7 +123,6 @@ void testRowsVoiceOut() { "Effective date", "2026-11-01T00:00:00Z", "Comment", "Increase and Billing increment change")); assertThat(rows.get(1).get("Category")).isEqualTo("Origin based"); - assertThat(rows.get(2).get("Prefix")).isEqualTo("99950,99951,99952"); assertThat(rows.get(3).get("Network")).isEqualTo("New Breakout"); assertThat(rows.get(3).get("Rate")).isEmpty(); assertThat(rows.get(3).get("Reject calls")).isEqualTo("true"); @@ -385,7 +384,7 @@ void testRowsReplaceInvalidUtf8() throws IOException { @Test void testRowsQuotedFields() { - String csv = "\"Prefix\",\"Rate (USD, excl. VAT)\",Comment\r\n" + String csv = "\"Prefix\",\"Rate, quoted\",Comment\r\n" + "\"99950,99951\",0.25,\"say \"\"hi\"\"\"\r\n" + "\r\n" + "49,,\"two\r\nlines\"\r\n" @@ -396,10 +395,10 @@ void testRowsQuotedFields() { assertThat(rows).hasSize(3); assertThat(rows.get(0)).containsExactlyEntriesOf(row( "Prefix", "99950,99951", - "Rate (USD, excl. VAT)", "0.25", + "Rate, quoted", "0.25", "Comment", "say \"hi\"")); assertThat(rows.get(1).get("Comment")).isEqualTo("two\nlines"); - assertThat(rows.get(1).get("Rate (USD, excl. VAT)")).isEmpty(); + assertThat(rows.get(1).get("Rate, quoted")).isEmpty(); assertThat(rows.get(2).get("Prefix")).as("spaces are kept").isEqualTo(" 1 "); assertThat(rows.get(2).get("Comment")).as("the last line needs no line end").isEqualTo("last"); } diff --git a/src/test/java/com/didww/sdk/prices/PriceFileTest.java b/src/test/java/com/didww/sdk/prices/PriceFileTest.java index e5cbb4f6..56b667a8 100644 --- a/src/test/java/com/didww/sdk/prices/PriceFileTest.java +++ b/src/test/java/com/didww/sdk/prices/PriceFileTest.java @@ -5,12 +5,14 @@ import com.didww.sdk.exception.DidwwPricesException; import com.didww.sdk.exception.PricesErrorKind; import com.didww.sdk.support.InterruptingBody; +import com.didww.sdk.support.ObservedBody; import com.github.tomakehurst.wiremock.http.Fault; import com.github.tomakehurst.wiremock.verification.LoggedRequest; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; @@ -32,6 +34,7 @@ import java.nio.file.attribute.FileTime; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.function.Function; @@ -646,16 +649,45 @@ void testPriceFileDownloadToFileLeavesOtherTempFiles() throws IOException { serveFile(); Path path = dir.resolve("voice_in_prices.csv.gz"); Files.write(dir.resolve("voice_in_prices.csv.gz.tmp"), "concurrent download".getBytes(StandardCharsets.UTF_8)); - Files.write(dir.resolve(".voice_in_prices.csv.gz.0123456789abcdef.tmp"), new byte[]{1}); + Files.write(dir.resolve(".didww-0123456789abcdef.tmp"), new byte[]{1}); client.voiceInPrices().downloadToFile(path); assertThat(Files.readAllBytes(path)).isEqualTo(fixture); assertThat(Files.readString(dir.resolve("voice_in_prices.csv.gz.tmp"))).isEqualTo("concurrent download"); - assertThat(entries()).containsExactly(".voice_in_prices.csv.gz.0123456789abcdef.tmp", + assertThat(entries()).containsExactly(".didww-0123456789abcdef.tmp", "voice_in_prices.csv.gz", "voice_in_prices.csv.gz.tmp"); } + // The temporary file name does not grow with the destination name, so a + // name close to the 255-byte limit of most file systems still downloads. + @Test + void testPriceFileDownloadToFileWithLongName() throws IOException { + serveFile(); + String name = "p".repeat(243) + ".csv.gz"; + List> during = new ArrayList<>(); + DidwwClient observed = builder() + .httpClientBuilder(new OkHttpClient.Builder().addInterceptor(ObservedBody.interceptor(VOICE_IN_FILE, + () -> during.add(uncheckedEntries())))) + .build(); + + observed.voiceInPrices().downloadToFile(dir.resolve(name)); + + assertThat(during).hasSize(1); + assertThat(during.get(0)).singleElement(InstanceOfAssertFactories.STRING) + .matches("\\.didww-[0-9a-f]{16}\\.tmp"); + assertThat(entries()).containsExactly(name); + assertThat(Files.readAllBytes(dir.resolve(name))).isEqualTo(fixture); + } + + private List uncheckedEntries() { + try { + return entries(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + @Test void testPriceFileDownloadToFileErrorKeepsExistingFile() throws IOException { stubFile(jsonApi(404, fixture("prices/errors/price_plan_not_assigned.json"))); diff --git a/src/test/java/com/didww/sdk/resource/AddressRequirementTest.java b/src/test/java/com/didww/sdk/resource/AddressRequirementTest.java index 855b7b76..31fb4dca 100644 --- a/src/test/java/com/didww/sdk/resource/AddressRequirementTest.java +++ b/src/test/java/com/didww/sdk/resource/AddressRequirementTest.java @@ -26,6 +26,8 @@ void testListAddressRequirements() { assertThat(requirements).hasSize(5); assertThat(requirements.get(0).getId()).isEqualTo("b6c80acb-3952-4d53-9e62-fe2348c0636b"); assertThat(requirements.get(0).getIdentityType()).isEqualTo(IdentityType.ANY); + assertThat(requirements.get(0).getPersonalMandatoryFields()).containsExactly("country"); + assertThat(requirements.get(0).getBusinessMandatoryFields()).as("empty, never null").isEmpty(); } @Test @@ -53,10 +55,9 @@ void testFindAddressRequirement() { assertThat(requirement.getBusinessProofQty()).isEqualTo(1); assertThat(requirement.getAddressProofQty()).isEqualTo(1); assertThat(requirement.getRestrictionMessage()).isEqualTo("End User Registration is Required"); - assertThat(requirement.getPersonalMandatoryFields()).isNotNull(); - assertThat(requirement.getPersonalMandatoryFields()).contains("Birth Date"); - assertThat(requirement.getBusinessMandatoryFields()).isNotNull(); - assertThat(requirement.getBusinessMandatoryFields()).contains("Company ID"); + assertThat(requirement.getPersonalMandatoryFields()).containsExactly("birth_date", "country", "personal_tax_id"); + assertThat(requirement.getBusinessMandatoryFields()) + .containsExactly("id_number", "tax_id", "country", "company_reg_number", "personal_tax_id"); // relationship accessors assertThat(requirement.getCountry()).isNotNull(); assertThat(requirement.getCountry().getId()).isEqualTo("5b156dc2-327e-4665-bdc5-35cd8729b885"); @@ -72,5 +73,7 @@ void testFindAddressRequirement() { assertThat(requirement.getPersonalProofTypes()).hasSize(1); assertThat(requirement.getBusinessProofTypes()).hasSize(7); assertThat(requirement.getAddressProofTypes()).hasSize(1); + assertThat(requirement.getPersonalProofTypes()).extracting(ProofType::getEntityType).containsExactly("personal"); + assertThat(requirement.getAddressProofTypes()).extracting(ProofType::getEntityType).containsExactly("address"); } } diff --git a/src/test/java/com/didww/sdk/resource/EmergencyRequirementTest.java b/src/test/java/com/didww/sdk/resource/EmergencyRequirementTest.java index 71fa9ac1..ed11c12e 100644 --- a/src/test/java/com/didww/sdk/resource/EmergencyRequirementTest.java +++ b/src/test/java/com/didww/sdk/resource/EmergencyRequirementTest.java @@ -25,8 +25,8 @@ void testListEmergencyRequirements() { assertThat(first.getAddressAreaLevel()).isEqualTo("city"); assertThat(first.getPersonalAreaLevel()).isEqualTo("country"); assertThat(first.getBusinessAreaLevel()).isNull(); - assertThat(first.getAddressMandatoryFields()).containsExactly("street", "city", "postal_code"); - assertThat(first.getPersonalMandatoryFields()).containsExactly("first_name", "last_name"); + assertThat(first.getAddressMandatoryFields()).containsExactly("area"); + assertThat(first.getPersonalMandatoryFields()).containsExactly("birth_date", "id_number"); assertThat(first.getBusinessMandatoryFields()).isEmpty(); assertThat(first.getEstimateSetupTime()).isEqualTo("7-14 days"); assertThat(first.getRequirementRestrictionMessage()).isNull(); @@ -38,6 +38,8 @@ void testListEmergencyRequirements() { EmergencyRequirement businessOnly = records.get(1); assertThat(businessOnly.getPersonalAreaLevel()).isNull(); assertThat(businessOnly.getBusinessAreaLevel()).isEqualTo("world_wide"); + assertThat(businessOnly.getAddressMandatoryFields()).isEmpty(); + assertThat(businessOnly.getBusinessMandatoryFields()).containsExactly("contact_email"); assertThat(businessOnly.getMetaSetupPrice()).isEqualTo("0.0"); assertThat(businessOnly.getMetaMonthlyPrice()).isEqualTo("2.5"); } @@ -57,7 +59,8 @@ void testFindEmergencyRequirement() { assertThat(record.getBusinessAreaLevel()).isEqualTo("world_wide"); assertThat(record.getEstimateSetupTime()).isEqualTo("7-14 days"); assertThat(record.getRequirementRestrictionMessage()).isEqualTo("Additional compliance review is required for this country."); - assertThat(record.getBusinessMandatoryFields()).containsExactly("company_name", "tax_number", "registration_number"); + assertThat(record.getAddressMandatoryFields()).containsExactly("area"); + assertThat(record.getBusinessMandatoryFields()).containsExactly("id_number", "tax_id", "company_reg_number", "birth_date"); assertThat(record.getNumberType()).isEqualTo(NumberType.LOCAL); assertThat(record.getMetaSetupPrice()).isEqualTo("0.0"); assertThat(record.getMetaMonthlyPrice()).isEqualTo("2.5"); diff --git a/src/test/java/com/didww/sdk/resource/ExportTest.java b/src/test/java/com/didww/sdk/resource/ExportTest.java index a65c6a4d..aaf82455 100644 --- a/src/test/java/com/didww/sdk/resource/ExportTest.java +++ b/src/test/java/com/didww/sdk/resource/ExportTest.java @@ -3,6 +3,7 @@ import com.didww.sdk.BaseTest; import com.didww.sdk.repository.ApiResponse; import com.didww.sdk.resource.enums.ExportStatus; +import com.didww.sdk.resource.enums.ExportServiceType; import com.didww.sdk.resource.enums.ExportTrunkType; import com.didww.sdk.resource.enums.ExportType; import org.junit.jupiter.api.Test; @@ -190,7 +191,7 @@ void testCreateSmsInExport() { assertThat(created.getId()).isEqualTo("5a2d3e4f-6b7c-4d8e-9f0a-1b2c3d4e5f60"); assertThat(created.getExportType()).isEqualTo(ExportType.SMS_IN); - assertThat(created.getFilters()).containsEntry("trunk_type", Arrays.asList("SMPP SMSC", "HTTP IN")); + assertThat(created.getFilters()).containsEntry("trunk_type", Arrays.asList("smpp_smsc", "http_in")); } @Test @@ -206,7 +207,7 @@ void testCreateSmsOutExport() { filters.put("from", "2026-10-01 00:00:00"); filters.put("to", "2026-10-15 00:00:00"); filters.put("sms_trunk.id", Arrays.asList("b2c3d4e5-0000-4000-8000-000000000001", "b2c3d4e5-0000-4000-8000-000000000003")); - filters.put("service_type", "a2p"); + filters.put("service_type", ExportServiceType.A2P); filters.put("sender_id_verification.id", "c3d4e5f6-0000-4000-8000-000000000002"); Export export = new Export(); diff --git a/src/test/java/com/didww/sdk/resource/ProofTypeTest.java b/src/test/java/com/didww/sdk/resource/ProofTypeTest.java index 4b5281c1..e1cd8dc9 100644 --- a/src/test/java/com/didww/sdk/resource/ProofTypeTest.java +++ b/src/test/java/com/didww/sdk/resource/ProofTypeTest.java @@ -21,6 +21,7 @@ void testListProofTypes() { assertThat(proofTypes).isNotEmpty(); assertThat(proofTypes.get(0).getId()).isEqualTo("ab1fb565-ac55-4c73-bc55-64dc61e70169"); assertThat(proofTypes.get(0).getName()).isEqualTo("Utility Bill"); - assertThat(proofTypes.get(0).getEntityType()).isEqualTo("Address"); + assertThat(proofTypes).extracting(ProofType::getEntityType) + .containsExactly("address", "address", "address", "personal", "address"); } } diff --git a/src/test/java/com/didww/sdk/resource/VoiceInTrunkTest.java b/src/test/java/com/didww/sdk/resource/VoiceInTrunkTest.java index 4beb5803..3729fb76 100644 --- a/src/test/java/com/didww/sdk/resource/VoiceInTrunkTest.java +++ b/src/test/java/com/didww/sdk/resource/VoiceInTrunkTest.java @@ -9,7 +9,11 @@ import com.didww.sdk.resource.enums.DiversionRelayPolicy; import com.didww.sdk.resource.enums.NetworkProtocolPriority; import com.didww.sdk.resource.enums.Codec; +import com.didww.sdk.resource.enums.IceMode; +import com.didww.sdk.resource.enums.IntEnum; import com.didww.sdk.resource.enums.Pop; +import com.didww.sdk.resource.enums.RtcpFeedbackMode; +import com.didww.sdk.resource.enums.RtcpMuxMode; import com.fasterxml.jackson.databind.ObjectMapper; import com.didww.sdk.resource.enums.ReroutingDisconnectCode; import com.didww.sdk.resource.enums.RxDtmfFormat; @@ -22,6 +26,7 @@ import com.didww.sdk.resource.configuration.TrunkConfiguration; import org.junit.jupiter.api.Test; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.List; @@ -75,7 +80,10 @@ void testListSipConfigurationAttributes() { assertThat(config.getUsername()).isEqualTo("username"); assertThat(config.getHost()).isEqualTo("203.0.113.78"); assertThat(config.getPort()).isEqualTo(8060); - assertThat(config.getCodecIds()).containsExactly(Codec.PCMU, Codec.PCMA, Codec.G729); + assertThat(config.getCodecs()).containsExactly(Codec.PCMU, Codec.PCMA, Codec.G729, Codec.AMR_WB); + assertThat(config.getIceMode()).isEqualTo(IceMode.ACCEPT); + assertThat(config.getRtcpMuxMode()).isEqualTo(RtcpMuxMode.INITIATE); + assertThat(config.getRtcpFeedbackMode()).isEqualTo(RtcpFeedbackMode.DISABLED); assertThat(config.getTransportProtocolId()).isEqualTo(TransportProtocol.UDP); assertThat(config.getAuthEnabled()).isTrue(); assertThat(config.getAuthUser()).isEqualTo("auth_user"); @@ -103,7 +111,7 @@ void testListSipConfigurationAttributes() { @Test void testCreateSipTrunkWithReroutingDisconnectCodes() { wireMock.stubFor(post(urlPathEqualTo("/v3/voice_in_trunks")) - .withRequestBody(equalToJson(loadFixture("voice_in_trunks/create_sip_with_rerouting_request.json"), true, false)) + .withRequestBody(equalToJson(loadFixture("voice_in_trunks/create_sip_with_rerouting_request.json"), false, false)) .willReturn(aResponse() .withStatus(201) .withHeader("Content-Type", "application/vnd.api+json") @@ -114,7 +122,10 @@ void testCreateSipTrunkWithReroutingDisconnectCodes() { sipConfig.setHost("203.0.113.110"); sipConfig.setSstRefreshMethodId(SstRefreshMethod.INVITE); sipConfig.setPort(5060); - sipConfig.setCodecIds(Arrays.asList(Codec.PCMU, Codec.PCMA, Codec.G729, Codec.G723, Codec.TELEPHONE_EVENT)); + sipConfig.setCodecs(Arrays.asList(Codec.PCMA, Codec.PCMU, Codec.AMR_WB, Codec.TELEPHONE_EVENT)); + sipConfig.setIceMode(IceMode.ACCEPT); + sipConfig.setRtcpMuxMode(RtcpMuxMode.INITIATE); + sipConfig.setRtcpFeedbackMode(RtcpFeedbackMode.ACCEPT); sipConfig.setReroutingDisconnectCodeIds(Arrays.asList( ReroutingDisconnectCode.SIP_400_BAD_REQUEST, ReroutingDisconnectCode.SIP_402_PAYMENT_REQUIRED, @@ -247,7 +258,7 @@ void testUpdatePstnTrunk() { @Test void testUpdateSipTrunk() { wireMock.stubFor(patch(urlPathEqualTo("/v3/voice_in_trunks/a80006b6-4183-4865-8b99-7ebbd359a762")) - .withRequestBody(equalToJson(loadFixture("voice_in_trunks/update_sip_request.json"), true, false)) + .withRequestBody(equalToJson(loadFixture("voice_in_trunks/update_sip_request.json"), false, false)) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/vnd.api+json") @@ -257,7 +268,10 @@ void testUpdateSipTrunk() { sipConfig.setUsername("new-username"); sipConfig.setHost("203.0.113.110"); sipConfig.setPort(5060); - sipConfig.setCodecIds(Arrays.asList(Codec.PCMU, Codec.PCMA, Codec.G729, Codec.G723, Codec.TELEPHONE_EVENT)); + sipConfig.setCodecs(Arrays.asList(Codec.L16, Codec.PCMU)); + sipConfig.setIceMode(IceMode.INITIATE); + sipConfig.setRtcpMuxMode(RtcpMuxMode.DISABLED); + sipConfig.setRtcpFeedbackMode(RtcpFeedbackMode.INITIATE); sipConfig.setSstRefreshMethodId(SstRefreshMethod.INVITE); sipConfig.setMediaEncryptionMode(MediaEncryptionMode.ZRTP); sipConfig.setStirShakenMode(StirShakenMode.PAI); @@ -283,6 +297,19 @@ void testUpdateSipTrunk() { assertThat(updatedSipConfig.getReroutingDisconnectCodeIds()).hasSize(45); } + @Test + void testCodecIdsAreGone() throws Exception { + SipConfiguration config = new SipConfiguration(); + config.setCodecs(Arrays.asList(Codec.PCMU)); + + assertThat(new ObjectMapper().writeValueAsString(config)) + .contains("\"codecs\":[\"pcmu\"]") + .doesNotContain("codec_ids"); + assertThat(SipConfiguration.class.getMethods()).extracting(Method::getName) + .doesNotContain("getCodecIds", "setCodecIds"); + assertThat(IntEnum.class.isAssignableFrom(Codec.class)).as("codecs are names, not ids").isFalse(); + } + @Test void testDeleteVoiceInTrunk() { String id = "41b94706-325e-4704-a433-d65105758836"; diff --git a/src/test/java/com/didww/sdk/resource/VoiceOutTrunkTest.java b/src/test/java/com/didww/sdk/resource/VoiceOutTrunkTest.java index cdf67016..c6307909 100644 --- a/src/test/java/com/didww/sdk/resource/VoiceOutTrunkTest.java +++ b/src/test/java/com/didww/sdk/resource/VoiceOutTrunkTest.java @@ -7,8 +7,12 @@ import com.didww.sdk.resource.authenticationmethod.IpOnlyAuthenticationMethod; import com.didww.sdk.resource.authenticationmethod.TwilioAuthenticationMethod; import com.didww.sdk.resource.enums.DefaultDstAction; +import com.didww.sdk.resource.enums.IceMode; import com.didww.sdk.resource.enums.MediaEncryptionMode; import com.didww.sdk.resource.enums.OnCliMismatchAction; +import com.didww.sdk.resource.enums.RtcpFeedbackMode; +import com.didww.sdk.resource.enums.RtcpMuxMode; +import com.didww.sdk.resource.enums.VoiceOutCodec; import com.didww.sdk.resource.enums.VoiceOutTrunkStatus; import org.junit.jupiter.api.Test; @@ -71,6 +75,10 @@ void testFindVoiceOutTrunk() { assertThat(trunk.getThresholdAmount()).isEqualTo("200.0"); assertThat(trunk.getCallbackUrl()).isNull(); assertThat(trunk.getDstPrefixes()).containsExactly("370"); + assertThat(trunk.getCodecs()).containsExactly(VoiceOutCodec.OPUS, VoiceOutCodec.G722, VoiceOutCodec.PCMA); + assertThat(trunk.getIceMode()).isEqualTo(IceMode.INITIATE); + assertThat(trunk.getRtcpMuxMode()).isEqualTo(RtcpMuxMode.DISABLED); + assertThat(trunk.getRtcpFeedbackMode()).isEqualTo(RtcpFeedbackMode.ACCEPT); assertThat(trunk.getAllowedRtpIps()).isNull(); assertThat(trunk.getDids()).hasSize(2); assertThat(trunk.getDefaultDid()).isNotNull(); @@ -150,7 +158,7 @@ void testCreateVoiceOutTrunkWithTwilioAuth() { @Test void testCreateVoiceOutTrunk() { wireMock.stubFor(post(urlPathEqualTo("/v3/voice_out_trunks")) - .withRequestBody(equalToJson(loadFixture("voice_out_trunks/create_request.json"), true, false)) + .withRequestBody(equalToJson(loadFixture("voice_out_trunks/create_request.json"), false, false)) .willReturn(aResponse() .withStatus(201) .withHeader("Content-Type", "application/vnd.api+json") @@ -164,6 +172,10 @@ void testCreateVoiceOutTrunk() { VoiceOutTrunk trunk = new VoiceOutTrunk(); trunk.setName("java-test"); trunk.setOnCliMismatchAction(OnCliMismatchAction.REPLACE_CLI); + trunk.setCodecs(Arrays.asList(VoiceOutCodec.OPUS, VoiceOutCodec.PCMA, VoiceOutCodec.TELEPHONE_EVENT)); + trunk.setIceMode(IceMode.INITIATE); + trunk.setRtcpMuxMode(RtcpMuxMode.ACCEPT); + trunk.setRtcpFeedbackMode(RtcpFeedbackMode.INITIATE); trunk.setAuthenticationMethod(auth); trunk.setDefaultDid(did); trunk.setDids(Collections.singletonList(did)); @@ -183,7 +195,7 @@ void testCreateVoiceOutTrunk() { @Test void testUpdateVoiceOutTrunk() { wireMock.stubFor(patch(urlPathEqualTo("/v3/voice_out_trunks/425ce763-a3a9-49b4-af5b-ada1a65c8864")) - .withRequestBody(equalToJson(loadFixture("voice_out_trunks/update_request.json"), true, false)) + .withRequestBody(equalToJson(loadFixture("voice_out_trunks/update_request.json"), false, false)) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/vnd.api+json") @@ -200,6 +212,10 @@ void testUpdateVoiceOutTrunk() { trunk.setDstPrefixes(Collections.singletonList("370")); trunk.setForceSymmetricRtp(true); trunk.setRtpPing(true); + trunk.setCodecs(Arrays.asList(VoiceOutCodec.G722, VoiceOutCodec.OPUS, VoiceOutCodec.TELEPHONE_EVENT)); + trunk.setIceMode(IceMode.DISABLED); + trunk.setRtcpMuxMode(RtcpMuxMode.INITIATE); + trunk.setRtcpFeedbackMode(RtcpFeedbackMode.DISABLED); trunk.setAuthenticationMethod(auth); ApiResponse response = client.voiceOutTrunks().update(trunk); diff --git a/src/test/java/com/didww/sdk/resource/enums/EnumWireValuesTest.java b/src/test/java/com/didww/sdk/resource/enums/EnumWireValuesTest.java new file mode 100644 index 00000000..7043e66b --- /dev/null +++ b/src/test/java/com/didww/sdk/resource/enums/EnumWireValuesTest.java @@ -0,0 +1,47 @@ +package com.didww.sdk.resource.enums; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EnumWireValuesTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static List wire(Enum[] constants) { + return MAPPER.convertValue(constants, new TypeReference>() { }); + } + + @Test + void testInboundSipCodecs() { + assertThat(wire(Codec.values())).containsExactly("telephone_event", "g723", "g729", "pcmu", "pcma", + "speex", "gsm", "g726_32", "g721", "g726_24", "g726_40", "g726_16", "l16", "amr", "amr_wb"); + } + + @Test + void testVoiceOutCodecs() { + assertThat(wire(VoiceOutCodec.values())).containsExactly("telephone_event", "g723", "g729", "pcmu", "pcma", + "gsm", "g722", "opus", "amr", "amr_wb"); + } + + @Test + void testModes() { + assertThat(wire(IceMode.values())).containsExactly("disabled", "accept", "initiate"); + assertThat(wire(RtcpMuxMode.values())).containsExactly("disabled", "accept", "initiate"); + assertThat(wire(RtcpFeedbackMode.values())).containsExactly("disabled", "accept", "initiate"); + } + + @Test + void testPop() { + assertThat(wire(Pop.values())).containsExactly("nyc", "fra", "lac", "mia", "sg", "hk", "ams"); + } + + @Test + void testExportFilters() { + assertThat(wire(ExportTrunkType.values())).containsExactly("smpp_smsc", "smpp_esme", "sms_to_email", "http_in"); + assertThat(wire(ExportServiceType.values())).containsExactly("a2p", "p2p"); + } +} diff --git a/src/test/java/com/didww/sdk/support/ObservedBody.java b/src/test/java/com/didww/sdk/support/ObservedBody.java new file mode 100644 index 00000000..f74ef70d --- /dev/null +++ b/src/test/java/com/didww/sdk/support/ObservedBody.java @@ -0,0 +1,48 @@ +package com.didww.sdk.support; + +import okhttp3.Interceptor; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import okio.ForwardingSource; +import okio.Okio; + +import java.io.IOException; + +/** + * Replaces the 200 body of a request to one path with the same bytes from + * memory and runs {@code atEnd} when the SDK reaches the end of the data, + * that is while the download is still in progress. + */ +public final class ObservedBody { + + private ObservedBody() { + } + + public static Interceptor interceptor(String path, Runnable atEnd) { + return chain -> { + Response response = chain.proceed(chain.request()); + if (response.code() != 200 || !chain.request().url().encodedPath().equals(path)) { + return response; + } + ResponseBody original = response.body(); + byte[] bytes = original.bytes(); + ForwardingSource source = new ForwardingSource(new Buffer().write(bytes)) { + private boolean observed; + + @Override + public long read(Buffer sink, long byteCount) throws IOException { + long read = super.read(sink, byteCount); + if (read < 0 && !observed) { + observed = true; + atEnd.run(); + } + return read; + } + }; + return response.newBuilder() + .body(ResponseBody.create(Okio.buffer(source), original.contentType(), bytes.length)) + .build(); + }; + } +} diff --git a/src/test/resources/fixtures/address_requirements/index.json b/src/test/resources/fixtures/address_requirements/index.json index af0c10c1..c1053828 100644 --- a/src/test/resources/fixtures/address_requirements/index.json +++ b/src/test/resources/fixtures/address_requirements/index.json @@ -12,9 +12,9 @@ "business_proof_qty": 2, "address_proof_qty": 1, "personal_mandatory_fields": [ - "Country" + "country" ], - "business_mandatory_fields": null, + "business_mandatory_fields": [], "service_description_required": false, "restriction_message": "Needs end user registration.", "number_type": "local" @@ -82,10 +82,10 @@ "business_proof_qty": 1, "address_proof_qty": 1, "personal_mandatory_fields": [ - "Country" + "country" ], "business_mandatory_fields": [ - "Country" + "country" ], "service_description_required": false, "restriction_message": "restriction_message", @@ -153,9 +153,9 @@ "personal_proof_qty": 1, "business_proof_qty": 1, "address_proof_qty": 1, - "personal_mandatory_fields": null, + "personal_mandatory_fields": [], "business_mandatory_fields": [ - "Country" + "country" ], "service_description_required": false, "restriction_message": "restriction_message", @@ -224,10 +224,10 @@ "business_proof_qty": 1, "address_proof_qty": 0, "personal_mandatory_fields": [ - "Country" + "country" ], "business_mandatory_fields": [ - "Country" + "country" ], "service_description_required": true, "restriction_message": "restriction_message", @@ -295,8 +295,8 @@ "personal_proof_qty": 0, "business_proof_qty": 0, "address_proof_qty": 0, - "personal_mandatory_fields": null, - "business_mandatory_fields": null, + "personal_mandatory_fields": [], + "business_mandatory_fields": [], "service_description_required": false, "restriction_message": "restriction_message", "number_type": "local" diff --git a/src/test/resources/fixtures/address_requirements/show.json b/src/test/resources/fixtures/address_requirements/show.json index 5b2da9df..646d9196 100644 --- a/src/test/resources/fixtures/address_requirements/show.json +++ b/src/test/resources/fixtures/address_requirements/show.json @@ -11,16 +11,16 @@ "business_proof_qty": 1, "address_proof_qty": 1, "personal_mandatory_fields": [ - "Birth Date", - "Country", - "Personal Tax ID" + "birth_date", + "country", + "personal_tax_id" ], "business_mandatory_fields": [ - "Proof of ID", - "VAT Number / TAX Code", - "Country", - "Company ID", - "Representative Tax ID" + "id_number", + "tax_id", + "country", + "company_reg_number", + "personal_tax_id" ], "service_description_required": true, "restriction_message": "End User Registration is Required", @@ -180,7 +180,7 @@ "type": "proof_types", "attributes": { "name": "Drivers License", - "entity_type": "Personal" + "entity_type": "personal" } }, { @@ -188,7 +188,7 @@ "type": "proof_types", "attributes": { "name": "Business Registration Certificate / Incorporation Certificate", - "entity_type": "Business" + "entity_type": "business" } }, { @@ -196,7 +196,7 @@ "type": "proof_types", "attributes": { "name": "Trade License", - "entity_type": "Business" + "entity_type": "business" } }, { @@ -204,7 +204,7 @@ "type": "proof_types", "attributes": { "name": "Excerpt from the commercial register", - "entity_type": "Business" + "entity_type": "business" } }, { @@ -212,7 +212,7 @@ "type": "proof_types", "attributes": { "name": "Other", - "entity_type": "Business" + "entity_type": "business" } }, { @@ -220,7 +220,7 @@ "type": "proof_types", "attributes": { "name": "Passport", - "entity_type": "Business" + "entity_type": "business" } }, { @@ -228,7 +228,7 @@ "type": "proof_types", "attributes": { "name": "National ID", - "entity_type": "Business" + "entity_type": "business" } }, { @@ -236,7 +236,7 @@ "type": "proof_types", "attributes": { "name": "Drivers License", - "entity_type": "Business" + "entity_type": "business" } }, { @@ -244,7 +244,7 @@ "type": "proof_types", "attributes": { "name": "Copy of Phone Bill", - "entity_type": "Address" + "entity_type": "address" } } ], diff --git a/src/test/resources/fixtures/emergency_requirements/index.json b/src/test/resources/fixtures/emergency_requirements/index.json index c8b0c05b..2a286b8c 100644 --- a/src/test/resources/fixtures/emergency_requirements/index.json +++ b/src/test/resources/fixtures/emergency_requirements/index.json @@ -9,13 +9,11 @@ "personal_area_level": "country", "business_area_level": null, "address_mandatory_fields": [ - "street", - "city", - "postal_code" + "area" ], "personal_mandatory_fields": [ - "first_name", - "last_name" + "birth_date", + "id_number" ], "business_mandatory_fields": [], "estimate_setup_time": "7-14 days", @@ -43,13 +41,10 @@ "address_area_level": "country", "personal_area_level": null, "business_area_level": "world_wide", - "address_mandatory_fields": [ - "street", - "city" - ], + "address_mandatory_fields": [], "personal_mandatory_fields": [], "business_mandatory_fields": [ - "company_name" + "contact_email" ], "estimate_setup_time": "3-5 days", "requirement_restriction_message": null, diff --git a/src/test/resources/fixtures/emergency_requirements/show.json b/src/test/resources/fixtures/emergency_requirements/show.json index 1d647d25..1573358a 100644 --- a/src/test/resources/fixtures/emergency_requirements/show.json +++ b/src/test/resources/fixtures/emergency_requirements/show.json @@ -8,15 +8,14 @@ "personal_area_level": null, "business_area_level": "world_wide", "address_mandatory_fields": [ - "street", - "city", - "postal_code" + "area" ], "personal_mandatory_fields": [], "business_mandatory_fields": [ - "company_name", - "tax_number", - "registration_number" + "id_number", + "tax_id", + "company_reg_number", + "birth_date" ], "estimate_setup_time": "7-14 days", "requirement_restriction_message": "Additional compliance review is required for this country.", diff --git a/src/test/resources/fixtures/exports/create_sms_in.json b/src/test/resources/fixtures/exports/create_sms_in.json index d7c0665b..03db9355 100644 --- a/src/test/resources/fixtures/exports/create_sms_in.json +++ b/src/test/resources/fixtures/exports/create_sms_in.json @@ -14,8 +14,8 @@ "from": "2026-10-01 00:00:00", "to": "2026-10-15 00:00:00", "trunk_type": [ - "SMPP SMSC", - "HTTP IN" + "smpp_smsc", + "http_in" ], "destination_address_eq": "12125550100" } diff --git a/src/test/resources/fixtures/exports/create_sms_in_request.json b/src/test/resources/fixtures/exports/create_sms_in_request.json index 42ce47a6..eae5f3d5 100644 --- a/src/test/resources/fixtures/exports/create_sms_in_request.json +++ b/src/test/resources/fixtures/exports/create_sms_in_request.json @@ -1 +1 @@ -{"data":{"type":"exports","attributes":{"export_type":"sms_in","filters":{"from":"2026-10-01 00:00:00","to":"2026-10-15 00:00:00","trunk_type":["SMPP SMSC","HTTP IN"],"destination_address_eq":"12125550100"}}}} \ No newline at end of file +{"data":{"type":"exports","attributes":{"export_type":"sms_in","filters":{"from":"2026-10-01 00:00:00","to":"2026-10-15 00:00:00","trunk_type":["smpp_smsc","http_in"],"destination_address_eq":"12125550100"}}}} \ No newline at end of file diff --git a/src/test/resources/fixtures/prices/voice_in_prices.csv.gz b/src/test/resources/fixtures/prices/voice_in_prices.csv.gz index 5ddae26c36a236e15520cef91f9fe4efacedc9b4..502c6fd11511cb983049336e0f7b264aa3bbe981 100644 GIT binary patch literal 191 zcmV;w06_mAiwFP!00002|Amg-3c@f9h3|ceK0uI8rtE&+h~UMB4^X;HDqSjVD%;!J zGXIztLP&Dp%Q>lCB=!@uUJE_%g4BS}y680uo^A+x3~WfTaRiNGgxV3I&RX<{a3I4* zSz(CL-<5?Ej{H##UK1>|vNo*FiN#JF4P81n0BE!}TIHcED=7hBCv&lnxMUZyHzRiQ tS+N1RPJNrl)1g;3ye(QzZ-=k6B8MgT|H&Nl;*08se*i|eB`8Ay006}_RU7~S literal 213 zcmV;`04o0hp`+cD4H^s zi~aW*<35602napk+BVNR-w+vpC;-_VNr9pqMiq1(6&N6oo$HYw}ml?xHD5}>}km^&ejjq%(8rgW}W)eG`<{K%iWJft?9S!CM}=B5`14W$9(g7 P@rSlN8c!XI3K6zUp0pLj z#$k)>VUK2Tj<~+Sz5+)WZEYV^M=#u@6<_93Fk*D0I+Om8eyXa8{f(lWJbXnphNJvAng9Mk={-kLN1+W6>P+4{3Pq3bF`!o~KjzWR97| i`k#Jw-r-TKL+qv-dw%tHqKA*!jHAF=OtiWl{?*pU@p`6V!D^h2dHVQM;~e-jU7Id*@dC?bk#ZT27bw3=Z8{9%r1^>!L^4_}lv+sjCgoi? z?+|{aI~1A=u_j|K{D^$7xx(3trHWW&zOeW^J`7UP)%38goZn~lUVo8>=QbmQFi(;s umM}&YVl?-ZXfnMD=YQDA#}*G_?LSesz4LZd9EzRoPi^0ok)kvT0ssJQLyP7B diff --git a/src/test/resources/fixtures/proof_types/index.json b/src/test/resources/fixtures/proof_types/index.json index cc90a596..506d831c 100644 --- a/src/test/resources/fixtures/proof_types/index.json +++ b/src/test/resources/fixtures/proof_types/index.json @@ -5,7 +5,7 @@ "type": "proof_types", "attributes": { "name": "Utility Bill", - "entity_type": "Address" + "entity_type": "address" } }, { @@ -13,7 +13,7 @@ "type": "proof_types", "attributes": { "name": "Rental Receipt", - "entity_type": "Address" + "entity_type": "address" } }, { @@ -21,7 +21,7 @@ "type": "proof_types", "attributes": { "name": "Other", - "entity_type": "Address" + "entity_type": "address" } }, { @@ -29,7 +29,7 @@ "type": "proof_types", "attributes": { "name": "Drivers License", - "entity_type": "Personal" + "entity_type": "personal" } }, { @@ -37,7 +37,7 @@ "type": "proof_types", "attributes": { "name": "Copy of Phone Bill", - "entity_type": "Address" + "entity_type": "address" } } ], diff --git a/src/test/resources/fixtures/proofs/create.json b/src/test/resources/fixtures/proofs/create.json index 92353f79..2eb0ae2e 100644 --- a/src/test/resources/fixtures/proofs/create.json +++ b/src/test/resources/fixtures/proofs/create.json @@ -31,7 +31,7 @@ "type": "proof_types", "attributes": { "name": "Drivers License", - "entity_type": "Personal" + "entity_type": "personal" } } ], diff --git a/src/test/resources/fixtures/voice_in_trunk_groups/create.json b/src/test/resources/fixtures/voice_in_trunk_groups/create.json index 8112cf87..fcf1e630 100644 --- a/src/test/resources/fixtures/voice_in_trunk_groups/create.json +++ b/src/test/resources/fixtures/voice_in_trunk_groups/create.json @@ -48,11 +48,14 @@ "username": "{CALL_DID}", "host": "203.0.113.1", "port": null, - "codec_ids": [ - 9, - 8, - 6 + "codecs": [ + "pcmu", + "g729", + "telephone_event" ], + "ice_mode": "disabled", + "rtcp_mux_mode": "disabled", + "rtcp_feedback_mode": "disabled", "rx_dtmf_format_id": 1, "tx_dtmf_format_id": 1, "resolve_ruri": false, @@ -112,13 +115,16 @@ "username": "{DID}", "host": "didww.com", "port": null, - "codec_ids": [ - 9, - 10, - 8, - 7, - 6 + "codecs": [ + "pcmu", + "pcma", + "g729", + "g723", + "telephone_event" ], + "ice_mode": "disabled", + "rtcp_mux_mode": "disabled", + "rtcp_feedback_mode": "disabled", "rx_dtmf_format_id": 1, "tx_dtmf_format_id": 1, "resolve_ruri": false, diff --git a/src/test/resources/fixtures/voice_in_trunk_groups/index.json b/src/test/resources/fixtures/voice_in_trunk_groups/index.json index fff2d2cf..0926c49e 100644 --- a/src/test/resources/fixtures/voice_in_trunk_groups/index.json +++ b/src/test/resources/fixtures/voice_in_trunk_groups/index.json @@ -50,13 +50,16 @@ "username": "{DID}", "host": "didww.com", "port": null, - "codec_ids": [ - 9, - 10, - 8, - 7, - 6 + "codecs": [ + "pcmu", + "pcma", + "g729", + "g723", + "telephone_event" ], + "ice_mode": "disabled", + "rtcp_mux_mode": "disabled", + "rtcp_feedback_mode": "disabled", "rx_dtmf_format_id": 1, "tx_dtmf_format_id": 1, "resolve_ruri": false, @@ -113,13 +116,16 @@ "username": "{DID}", "host": "didww.com", "port": null, - "codec_ids": [ - 9, - 10, - 8, - 7, - 6 + "codecs": [ + "pcmu", + "pcma", + "g729", + "g723", + "telephone_event" ], + "ice_mode": "disabled", + "rtcp_mux_mode": "disabled", + "rtcp_feedback_mode": "disabled", "rx_dtmf_format_id": 1, "tx_dtmf_format_id": 1, "resolve_ruri": false, diff --git a/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting.json b/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting.json index cfe4eaa3..8e686a6f 100644 --- a/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting.json +++ b/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting.json @@ -25,13 +25,15 @@ "username": "username", "host": "203.0.113.110", "port": 5060, - "codec_ids": [ - 9, - 10, - 8, - 7, - 6 + "codecs": [ + "pcma", + "pcmu", + "amr_wb", + "telephone_event" ], + "ice_mode": "accept", + "rtcp_mux_mode": "initiate", + "rtcp_feedback_mode": "accept", "rx_dtmf_format_id": 1, "tx_dtmf_format_id": 1, "resolve_ruri": false, diff --git a/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting_request.json b/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting_request.json index 1ca82f1d..9ea70949 100644 --- a/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting_request.json +++ b/src/test/resources/fixtures/voice_in_trunks/create_sip_with_rerouting_request.json @@ -1 +1 @@ -{"data":{"type":"voice_in_trunks","attributes":{"configuration":{"type":"sip_configurations","attributes":{"username":"username","host":"203.0.113.110","enabled_sip_registration":false,"use_did_in_ruri":false,"sst_refresh_method_id":1,"port":5060,"codec_ids":[9,10,8,7,6],"rerouting_disconnect_code_ids":[56,58,59,60,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,86,87,88,89,90,91,92,96,97,98,99,101,102,103,104,105,106,107,108,1505],"media_encryption_mode":"zrtp","stir_shaken_mode":"pai","allowed_rtp_ips":["203.0.113.1"],"diversion_relay_policy":"as_is","diversion_inject_mode":"did_number","network_protocol_priority":"force_ipv4","cnam_lookup":true}},"name":"hello, test sip trunk"}}} \ No newline at end of file +{"data":{"type":"voice_in_trunks","attributes":{"configuration":{"type":"sip_configurations","attributes":{"username":"username","host":"203.0.113.110","enabled_sip_registration":false,"use_did_in_ruri":false,"sst_refresh_method_id":1,"port":5060,"codecs":["pcma","pcmu","amr_wb","telephone_event"],"ice_mode":"accept","rtcp_mux_mode":"initiate","rtcp_feedback_mode":"accept","rerouting_disconnect_code_ids":[56,58,59,60,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,86,87,88,89,90,91,92,96,97,98,99,101,102,103,104,105,106,107,108,1505],"media_encryption_mode":"zrtp","stir_shaken_mode":"pai","allowed_rtp_ips":["203.0.113.1"],"diversion_relay_policy":"as_is","diversion_inject_mode":"did_number","network_protocol_priority":"force_ipv4","cnam_lookup":true}},"name":"hello, test sip trunk"}}} \ No newline at end of file diff --git a/src/test/resources/fixtures/voice_in_trunks/create_with_pop.json b/src/test/resources/fixtures/voice_in_trunks/create_with_pop.json index f2b3172b..ce03e2c0 100644 --- a/src/test/resources/fixtures/voice_in_trunks/create_with_pop.json +++ b/src/test/resources/fixtures/voice_in_trunks/create_with_pop.json @@ -19,7 +19,7 @@ } }, "created_at": "2018-12-29T09:51:54.661Z", - "pop": "FRA" + "pop": "fra" }, "relationships": { "voice_in_trunk_group": { diff --git a/src/test/resources/fixtures/voice_in_trunks/create_with_pop_request.json b/src/test/resources/fixtures/voice_in_trunks/create_with_pop_request.json index 32fd0484..9f2fb18f 100644 --- a/src/test/resources/fixtures/voice_in_trunks/create_with_pop_request.json +++ b/src/test/resources/fixtures/voice_in_trunks/create_with_pop_request.json @@ -1 +1 @@ -{"data":{"type":"voice_in_trunks","attributes":{"configuration":{"type":"pstn_configurations","attributes":{"dst":"558540420024"}},"name":"hello, test pstn trunk","pop":"FRA"}}} \ No newline at end of file +{"data":{"type":"voice_in_trunks","attributes":{"configuration":{"type":"pstn_configurations","attributes":{"dst":"558540420024"}},"name":"hello, test pstn trunk","pop":"fra"}}} \ No newline at end of file diff --git a/src/test/resources/fixtures/voice_in_trunks/index.json b/src/test/resources/fixtures/voice_in_trunks/index.json index 83dc568e..8791cf82 100644 --- a/src/test/resources/fixtures/voice_in_trunks/index.json +++ b/src/test/resources/fixtures/voice_in_trunks/index.json @@ -57,11 +57,15 @@ "username": "username", "host": "203.0.113.78", "port": 8060, - "codec_ids": [ - 9, - 10, - 8 + "codecs": [ + "pcmu", + "pcma", + "g729", + "amr_wb" ], + "ice_mode": "accept", + "rtcp_mux_mode": "initiate", + "rtcp_feedback_mode": "disabled", "rx_dtmf_format_id": 1, "tx_dtmf_format_id": 1, "resolve_ruri": true, @@ -92,7 +96,7 @@ } }, "created_at": "2018-12-28T15:00:43.522Z", - "pop": "FRA" + "pop": "fra" }, "relationships": { "voice_in_trunk_group": { diff --git a/src/test/resources/fixtures/voice_in_trunks/update_sip.json b/src/test/resources/fixtures/voice_in_trunks/update_sip.json index 46afb825..871ee6dd 100644 --- a/src/test/resources/fixtures/voice_in_trunks/update_sip.json +++ b/src/test/resources/fixtures/voice_in_trunks/update_sip.json @@ -25,13 +25,13 @@ "username": "new-username", "host": "203.0.113.110", "port": 5060, - "codec_ids": [ - 9, - 10, - 8, - 7, - 6 + "codecs": [ + "l16", + "pcmu" ], + "ice_mode": "initiate", + "rtcp_mux_mode": "disabled", + "rtcp_feedback_mode": "initiate", "rx_dtmf_format_id": 1, "tx_dtmf_format_id": 1, "resolve_ruri": false, diff --git a/src/test/resources/fixtures/voice_in_trunks/update_sip_request.json b/src/test/resources/fixtures/voice_in_trunks/update_sip_request.json index cb78defb..01708b59 100644 --- a/src/test/resources/fixtures/voice_in_trunks/update_sip_request.json +++ b/src/test/resources/fixtures/voice_in_trunks/update_sip_request.json @@ -13,7 +13,10 @@ "enabled_sip_registration": false, "use_did_in_ruri": false, "port": 5060, - "codec_ids": [9, 10, 8, 7, 6], + "codecs": ["l16", "pcmu"], + "ice_mode": "initiate", + "rtcp_mux_mode": "disabled", + "rtcp_feedback_mode": "initiate", "sst_refresh_method_id": 1, "media_encryption_mode": "zrtp", "stir_shaken_mode": "pai", diff --git a/src/test/resources/fixtures/voice_out_trunks/create.json b/src/test/resources/fixtures/voice_out_trunks/create.json index 013cb05b..301c3bc9 100644 --- a/src/test/resources/fixtures/voice_out_trunks/create.json +++ b/src/test/resources/fixtures/voice_out_trunks/create.json @@ -17,6 +17,14 @@ "dst_prefixes": [], "force_symmetric_rtp": false, "rtp_ping": false, + "codecs": [ + "opus", + "pcma", + "telephone_event" + ], + "ice_mode": "initiate", + "rtcp_mux_mode": "accept", + "rtcp_feedback_mode": "initiate", "callback_url": null, "external_reference_id": null, "emergency_enable_all": false, diff --git a/src/test/resources/fixtures/voice_out_trunks/create_request.json b/src/test/resources/fixtures/voice_out_trunks/create_request.json index c900c562..ad1c75ac 100644 --- a/src/test/resources/fixtures/voice_out_trunks/create_request.json +++ b/src/test/resources/fixtures/voice_out_trunks/create_request.json @@ -1 +1 @@ -{"data":{"type":"voice_out_trunks","attributes":{"name":"java-test","on_cli_mismatch_action":"replace_cli","authentication_method":{"type":"credentials_and_ip","attributes":{"allowed_sip_ips":["203.0.113.0/24"]}}},"relationships":{"default_did":{"data":{"type":"dids","id":"7a028c32-e6b6-4c86-bf01-90f901b37012"}},"dids":{"data":[{"type":"dids","id":"7a028c32-e6b6-4c86-bf01-90f901b37012"}]}}}} +{"data":{"type":"voice_out_trunks","attributes":{"name":"java-test","on_cli_mismatch_action":"replace_cli","codecs":["opus","pcma","telephone_event"],"ice_mode":"initiate","rtcp_mux_mode":"accept","rtcp_feedback_mode":"initiate","authentication_method":{"type":"credentials_and_ip","attributes":{"allowed_sip_ips":["203.0.113.0/24"]}}},"relationships":{"default_did":{"data":{"type":"dids","id":"7a028c32-e6b6-4c86-bf01-90f901b37012"}},"dids":{"data":[{"type":"dids","id":"7a028c32-e6b6-4c86-bf01-90f901b37012"}]}}}} diff --git a/src/test/resources/fixtures/voice_out_trunks/create_twilio.json b/src/test/resources/fixtures/voice_out_trunks/create_twilio.json index cbc1c7b5..9ad1adec 100644 --- a/src/test/resources/fixtures/voice_out_trunks/create_twilio.json +++ b/src/test/resources/fixtures/voice_out_trunks/create_twilio.json @@ -17,6 +17,14 @@ "dst_prefixes": [], "force_symmetric_rtp": false, "rtp_ping": false, + "codecs": [ + "pcma", + "pcmu", + "telephone_event" + ], + "ice_mode": "accept", + "rtcp_mux_mode": "accept", + "rtcp_feedback_mode": "accept", "callback_url": null, "external_reference_id": null, "authentication_method": { diff --git a/src/test/resources/fixtures/voice_out_trunks/index.json b/src/test/resources/fixtures/voice_out_trunks/index.json index 5eef768b..342d9e76 100644 --- a/src/test/resources/fixtures/voice_out_trunks/index.json +++ b/src/test/resources/fixtures/voice_out_trunks/index.json @@ -20,6 +20,14 @@ ], "force_symmetric_rtp": true, "rtp_ping": true, + "codecs": [ + "pcma", + "pcmu", + "telephone_event" + ], + "ice_mode": "accept", + "rtcp_mux_mode": "accept", + "rtcp_feedback_mode": "accept", "callback_url": null, "emergency_enable_all": false, "rtp_timeout": 30, @@ -76,6 +84,14 @@ "dst_prefixes": [], "force_symmetric_rtp": false, "rtp_ping": false, + "codecs": [ + "pcma", + "pcmu", + "telephone_event" + ], + "ice_mode": "accept", + "rtcp_mux_mode": "accept", + "rtcp_feedback_mode": "accept", "callback_url": null, "emergency_enable_all": false, "rtp_timeout": null, diff --git a/src/test/resources/fixtures/voice_out_trunks/show.json b/src/test/resources/fixtures/voice_out_trunks/show.json index e46e3dc4..ea294f10 100644 --- a/src/test/resources/fixtures/voice_out_trunks/show.json +++ b/src/test/resources/fixtures/voice_out_trunks/show.json @@ -19,6 +19,14 @@ ], "force_symmetric_rtp": true, "rtp_ping": true, + "codecs": [ + "opus", + "g722", + "pcma" + ], + "ice_mode": "initiate", + "rtcp_mux_mode": "disabled", + "rtcp_feedback_mode": "accept", "callback_url": null, "authentication_method": { "type": "credentials_and_ip", diff --git a/src/test/resources/fixtures/voice_out_trunks/show_ip_only.json b/src/test/resources/fixtures/voice_out_trunks/show_ip_only.json index 7fff18a8..63d2b760 100644 --- a/src/test/resources/fixtures/voice_out_trunks/show_ip_only.json +++ b/src/test/resources/fixtures/voice_out_trunks/show_ip_only.json @@ -17,6 +17,14 @@ "dst_prefixes": [], "force_symmetric_rtp": false, "rtp_ping": false, + "codecs": [ + "pcma", + "pcmu", + "telephone_event" + ], + "ice_mode": "accept", + "rtcp_mux_mode": "accept", + "rtcp_feedback_mode": "accept", "callback_url": null, "external_reference_id": null, "authentication_method": { diff --git a/src/test/resources/fixtures/voice_out_trunks/show_twilio.json b/src/test/resources/fixtures/voice_out_trunks/show_twilio.json index bf35b4da..4154cb91 100644 --- a/src/test/resources/fixtures/voice_out_trunks/show_twilio.json +++ b/src/test/resources/fixtures/voice_out_trunks/show_twilio.json @@ -17,6 +17,14 @@ "dst_prefixes": [], "force_symmetric_rtp": false, "rtp_ping": false, + "codecs": [ + "pcma", + "pcmu", + "telephone_event" + ], + "ice_mode": "accept", + "rtcp_mux_mode": "accept", + "rtcp_feedback_mode": "accept", "callback_url": null, "external_reference_id": null, "authentication_method": { diff --git a/src/test/resources/fixtures/voice_out_trunks/update.json b/src/test/resources/fixtures/voice_out_trunks/update.json index 6937b06e..22415219 100644 --- a/src/test/resources/fixtures/voice_out_trunks/update.json +++ b/src/test/resources/fixtures/voice_out_trunks/update.json @@ -19,6 +19,14 @@ ], "force_symmetric_rtp": true, "rtp_ping": true, + "codecs": [ + "g722", + "opus", + "telephone_event" + ], + "ice_mode": "disabled", + "rtcp_mux_mode": "initiate", + "rtcp_feedback_mode": "disabled", "callback_url": null, "authentication_method": { "type": "ip_only", diff --git a/src/test/resources/fixtures/voice_out_trunks/update_request.json b/src/test/resources/fixtures/voice_out_trunks/update_request.json index e7352ebb..8abdf58c 100644 --- a/src/test/resources/fixtures/voice_out_trunks/update_request.json +++ b/src/test/resources/fixtures/voice_out_trunks/update_request.json @@ -10,6 +10,10 @@ "dst_prefixes": ["370"], "force_symmetric_rtp": true, "rtp_ping": true, + "codecs": ["g722", "opus", "telephone_event"], + "ice_mode": "disabled", + "rtcp_mux_mode": "initiate", + "rtcp_feedback_mode": "disabled", "authentication_method": { "type": "ip_only", "attributes": { From 7cb895b6b9f06110a6a282541fabaccaf22da279 Mon Sep 17 00:00:00 2001 From: Denys Metelov Date: Tue, 10 Nov 2026 15:18:04 +0100 Subject: [PATCH 2/2] test: compute the origin outside the asserted lambda --- src/test/java/com/didww/sdk/TooManyRequestsTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/didww/sdk/TooManyRequestsTest.java b/src/test/java/com/didww/sdk/TooManyRequestsTest.java index d97eef88..8df6175a 100644 --- a/src/test/java/com/didww/sdk/TooManyRequestsTest.java +++ b/src/test/java/com/didww/sdk/TooManyRequestsTest.java @@ -73,7 +73,8 @@ private void answerEveryApiCall(ResponseDefinitionBuilder response) { } private DidwwApiException failure(Operation operation) { - DidwwApiException e = catchThrowableOfType(() -> operation.run(client, origin(), dir), DidwwApiException.class); + String origin = origin(); + DidwwApiException e = catchThrowableOfType(() -> operation.run(client, origin, dir), DidwwApiException.class); assertThat(e).as("a DidwwApiException").isNotNull(); return e; }