From 041991ee1b37bfa42678a4d71701859598eac790 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Tue, 26 May 2026 09:32:09 -0400 Subject: [PATCH 1/3] DEVX-11247: Add Reports API client - Add ReportsClient with 5 endpoint methods: getRecords, createReport, getReport, cancelReport, downloadReport - Add AsyncReportRequest (POST body), RecordsFilter (GET query params), ReportResponse, RecordsResponse, Product, ReportStatus, ReportsResponseException - Wire ReportsClient into VonageClient - Add 36 unit tests (all passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + .../java/com/vonage/client/VonageClient.java | 13 + .../client/reports/AsyncReportRequest.java | 840 +++++++++++++++++ .../com/vonage/client/reports/Product.java | 68 ++ .../vonage/client/reports/RecordsFilter.java | 885 ++++++++++++++++++ .../client/reports/RecordsResponse.java | 158 ++++ .../vonage/client/reports/ReportResponse.java | 489 ++++++++++ .../vonage/client/reports/ReportStatus.java | 47 + .../vonage/client/reports/ReportsClient.java | 205 ++++ .../reports/ReportsResponseException.java | 46 + .../vonage/client/reports/package-info.java | 35 + .../client/reports/ReportsClientTest.java | 557 +++++++++++ 12 files changed, 3346 insertions(+) create mode 100644 src/main/java/com/vonage/client/reports/AsyncReportRequest.java create mode 100644 src/main/java/com/vonage/client/reports/Product.java create mode 100644 src/main/java/com/vonage/client/reports/RecordsFilter.java create mode 100644 src/main/java/com/vonage/client/reports/RecordsResponse.java create mode 100644 src/main/java/com/vonage/client/reports/ReportResponse.java create mode 100644 src/main/java/com/vonage/client/reports/ReportStatus.java create mode 100644 src/main/java/com/vonage/client/reports/ReportsClient.java create mode 100644 src/main/java/com/vonage/client/reports/ReportsResponseException.java create mode 100644 src/main/java/com/vonage/client/reports/package-info.java create mode 100644 src/test/java/com/vonage/client/reports/ReportsClientTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 350a44ec1..64ea30fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +# [Unreleased] +- Reports: Added new Reports API client covering synchronous records retrieval (`getRecords`), asynchronous report creation (`createReport`), report status polling (`getReport`), report cancellation (`cancelReport`), and report download (`downloadReport`) + # [9.10.2] - HTTP: Fixed stale pooled connection reuse by adding connection TTL, idle/expired eviction and inactivity validation to reduce intermittent `Connection reset` errors when reusing long-lived `VonageClient` instances diff --git a/src/main/java/com/vonage/client/VonageClient.java b/src/main/java/com/vonage/client/VonageClient.java index f74616a41..3b7ea335e 100644 --- a/src/main/java/com/vonage/client/VonageClient.java +++ b/src/main/java/com/vonage/client/VonageClient.java @@ -27,6 +27,7 @@ import com.vonage.client.conversations.ConversationsClient; import com.vonage.client.identityinsights.IdentityInsightsClient; import com.vonage.client.conversion.ConversionClient; +import com.vonage.client.reports.ReportsClient; import com.vonage.client.insight.InsightClient; import com.vonage.client.messages.MessagesClient; import com.vonage.client.numbers.NumbersClient; @@ -85,6 +86,7 @@ public class VonageClient { private final SimSwapClient simSwap; private final NumberVerificationClient numberVerification; private final IdentityInsightsClient identityInsights; + private final ReportsClient reports; /** * Constructor which uses the builder pattern for instantiation. @@ -114,6 +116,7 @@ private VonageClient(Builder builder) { simSwap = new SimSwapClient(httpWrapper); numberVerification = new NumberVerificationClient(httpWrapper); identityInsights = new IdentityInsightsClient(httpWrapper); + reports = new ReportsClient(httpWrapper); } /** @@ -329,6 +332,16 @@ public IdentityInsightsClient getIdentityInsightsClient() { return identityInsights; } + /** + * Returns the Reports API client. + * + * @return The {@linkplain ReportsClient}. + * @since 9.9.0 + */ + public ReportsClient getReportsClient() { + return reports; + } + /** * Generate a JWT for the application the client has been configured with. * diff --git a/src/main/java/com/vonage/client/reports/AsyncReportRequest.java b/src/main/java/com/vonage/client/reports/AsyncReportRequest.java new file mode 100644 index 000000000..ae243c49f --- /dev/null +++ b/src/main/java/com/vonage/client/reports/AsyncReportRequest.java @@ -0,0 +1,840 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.reports; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.vonage.client.JsonableBaseObject; +import java.net.URI; +import java.util.Objects; + +/** + * Request body for creating an asynchronous report ({@code POST /v2/reports}). + *

+ * Build using {@link #builder(Product, String)}, providing the required {@code product} and + * {@code account_id}. Optional parameters vary by product type — refer to the + * Reports API documentation for details. + *

+ */ +public class AsyncReportRequest extends JsonableBaseObject { + + @JsonProperty("product") + private Product product; + + @JsonProperty("account_id") + private String accountId; + + @JsonProperty("date_start") + private String dateStart; + + @JsonProperty("date_end") + private String dateEnd; + + @JsonProperty("include_subaccounts") + private Boolean includeSubaccounts; + + @JsonProperty("callback_url") + private URI callbackUrl; + + // Product-specific fields (optional, null = excluded from JSON) + + @JsonProperty("direction") + private String direction; + + @JsonProperty("status") + private String status; + + @JsonProperty("from") + private String from; + + @JsonProperty("to") + private String to; + + @JsonProperty("country") + private String country; + + @JsonProperty("network") + private String network; + + @JsonProperty("client_ref") + private String clientRef; + + @JsonProperty("account_ref") + private String accountRef; + + @JsonProperty("include_message") + private Boolean includeMessage; + + @JsonProperty("show_concatenated") + private Boolean showConcatenated; + + @JsonProperty("call_id") + private String callId; + + @JsonProperty("conversation_id") + private String conversationId; + + @JsonProperty("leg_id") + private String legId; + + @JsonProperty("provider") + private String provider; + + @JsonProperty("channel") + private String channel; + + @JsonProperty("parent_request_id") + private String parentRequestId; + + @JsonProperty("locale") + private String locale; + + @JsonProperty("number") + private String number; + + @JsonProperty("product_name") + private String productName; + + @JsonProperty("request_type") + private String requestType; + + @JsonProperty("request_session_id") + private String requestSessionId; + + @JsonProperty("product_path") + private String productPath; + + @JsonProperty("correlation_id") + private String correlationId; + + @JsonProperty("session_id") + private String sessionId; + + @JsonProperty("meeting_id") + private String meetingId; + + private AsyncReportRequest() {} + + private AsyncReportRequest(Builder builder) { + product = Objects.requireNonNull(builder.product, "Product is required."); + accountId = Objects.requireNonNull(builder.accountId, "Account ID is required."); + if (accountId.trim().isEmpty()) { + throw new IllegalArgumentException("Account ID cannot be empty."); + } + dateStart = builder.dateStart; + dateEnd = builder.dateEnd; + includeSubaccounts = builder.includeSubaccounts; + callbackUrl = builder.callbackUrl; + direction = builder.direction; + status = builder.status; + from = builder.from; + to = builder.to; + country = builder.country; + network = builder.network; + clientRef = builder.clientRef; + accountRef = builder.accountRef; + includeMessage = builder.includeMessage; + showConcatenated = builder.showConcatenated; + callId = builder.callId; + conversationId = builder.conversationId; + legId = builder.legId; + provider = builder.provider; + channel = builder.channel; + parentRequestId = builder.parentRequestId; + locale = builder.locale; + number = builder.number; + productName = builder.productName; + requestType = builder.requestType; + requestSessionId = builder.requestSessionId; + productPath = builder.productPath; + correlationId = builder.correlationId; + sessionId = builder.sessionId; + meetingId = builder.meetingId; + } + + /** + * The product type for this report. + * + * @return The product enum value. + */ + public Product getProduct() { + return product; + } + + /** + * The account ID (API key) to report on. + * + * @return The account ID string. + */ + public String getAccountId() { + return accountId; + } + + /** + * Start date for the report in ISO-8601 format. + * + * @return The start date string, or {@code null} if not set. + */ + public String getDateStart() { + return dateStart; + } + + /** + * End date for the report in ISO-8601 format. + * + * @return The end date string, or {@code null} if not set. + */ + public String getDateEnd() { + return dateEnd; + } + + /** + * Whether to include sub-account data in the report. + * + * @return {@code true} if sub-accounts are included, or {@code null} if not set. + */ + public Boolean getIncludeSubaccounts() { + return includeSubaccounts; + } + + /** + * Webhook URL to receive a notification when the report is ready. + * + * @return The callback URL, or {@code null} if not set. + */ + public URI getCallbackUrl() { + return callbackUrl; + } + + /** + * Direction filter: {@code inbound} or {@code outbound}. + * + * @return The direction string, or {@code null} if not set. + */ + public String getDirection() { + return direction; + } + + /** + * Status filter for the records. + * + * @return The status string, or {@code null} if not set. + */ + public String getStatus() { + return status; + } + + /** + * Sender ID or phone number filter. + * + * @return The sender filter string, or {@code null} if not set. + */ + public String getFrom() { + return from; + } + + /** + * Recipient phone number filter. + * + * @return The recipient filter string, or {@code null} if not set. + */ + public String getTo() { + return to; + } + + /** + * ISO two-letter country code filter. + * + * @return The country code, or {@code null} if not set. + */ + public String getCountry() { + return country; + } + + /** + * Mobile network code filter. + * + * @return The network code, or {@code null} if not set. + */ + public String getNetwork() { + return network; + } + + /** + * Custom client reference filter (SMS). + * + * @return The client reference string, or {@code null} if not set. + */ + public String getClientRef() { + return clientRef; + } + + /** + * Account reference filter (SMS). + * + * @return The account reference string, or {@code null} if not set. + */ + public String getAccountRef() { + return accountRef; + } + + /** + * Whether to include message body content in the report. + * + * @return {@code true} if message content is included, or {@code null} if not set. + */ + public Boolean getIncludeMessage() { + return includeMessage; + } + + /** + * Whether to include concatenation info in the report (SMS outbound only). + * + * @return {@code true} if concatenation info is included, or {@code null} if not set. + */ + public Boolean getShowConcatenated() { + return showConcatenated; + } + + /** + * Call identifier filter (VOICE-CALL, VOICE-FAILED, ASR, WEBSOCKET-CALL, AMD). + * + * @return The call ID, or {@code null} if not set. + */ + public String getCallId() { + return callId; + } + + /** + * Conversation ID filter (IN-APP-VOICE, CONVERSATION-EVENT, CONVERSATION-MESSAGE). + * + * @return The conversation ID, or {@code null} if not set. + */ + public String getConversationId() { + return conversationId; + } + + /** + * Leg ID filter (IN-APP-VOICE). + * + * @return The leg ID, or {@code null} if not set. + */ + public String getLegId() { + return legId; + } + + /** + * Messaging provider filter (MESSAGES). + * + * @return The provider string (e.g. {@code whatsapp}), or {@code null} if not set. + */ + public String getProvider() { + return provider; + } + + /** + * Verification channel filter (VERIFY-V2). + * + * @return The channel string, or {@code null} if not set. + */ + public String getChannel() { + return channel; + } + + /** + * Parent request ID filter to correlate v2 verification events (VERIFY-V2). + * + * @return The parent request ID, or {@code null} if not set. + */ + public String getParentRequestId() { + return parentRequestId; + } + + /** + * Language/locale filter (VERIFY-V2). + * + * @return The locale string, or {@code null} if not set. + */ + public String getLocale() { + return locale; + } + + /** + * Phone number filter for Number Insight reports. + * + * @return The phone number, or {@code null} if not set. + */ + public String getNumber() { + return number; + } + + /** + * Product name filter for Network API events (NETWORK-API-EVENT). + * + * @return The product name, or {@code null} if not set. + */ + public String getProductName() { + return productName; + } + + /** + * Request type filter for Network API events (NETWORK-API-EVENT). + * + * @return The request type, or {@code null} if not set. + */ + public String getRequestType() { + return requestType; + } + + /** + * Session ID filter for Network API events (NETWORK-API-EVENT). + * + * @return The request session ID, or {@code null} if not set. + */ + public String getRequestSessionId() { + return requestSessionId; + } + + /** + * API path filter for Network API events (NETWORK-API-EVENT). + * + * @return The product path, or {@code null} if not set. + */ + public String getProductPath() { + return productPath; + } + + /** + * Correlation ID filter for Network API events (NETWORK-API-EVENT). + * + * @return The correlation ID, or {@code null} if not set. + */ + public String getCorrelationId() { + return correlationId; + } + + /** + * Video session ID filter (VIDEO-API). + * + * @return The session ID, or {@code null} if not set. + */ + public String getSessionId() { + return sessionId; + } + + /** + * Meeting ID filter (VIDEO-API). + * + * @return The meeting ID, or {@code null} if not set. + */ + public String getMeetingId() { + return meetingId; + } + + /** + * Entry point for constructing an instance of this class. + * + * @param product The product type for the report. + * @param accountId The account ID (API key) to report on. + * + * @return A new Builder. + */ + public static Builder builder(Product product, String accountId) { + return new Builder(product, accountId); + } + + /** + * Builder for {@link AsyncReportRequest}. + */ + public static class Builder { + private final Product product; + private final String accountId; + private String dateStart, dateEnd, direction, status, from, to, country, network; + private String clientRef, accountRef, callId, conversationId, legId, provider; + private String channel, parentRequestId, locale, number; + private String productName, requestType, requestSessionId, productPath, correlationId; + private String sessionId, meetingId; + private Boolean includeSubaccounts, includeMessage, showConcatenated; + private URI callbackUrl; + + private Builder(Product product, String accountId) { + this.product = product; + this.accountId = accountId; + } + + /** + * Start date for the report in ISO-8601 format (e.g. {@code 2024-02-02T13:50:00+00:00}). + * Defaults to seven days ago if not specified. + * + * @param dateStart The start date string. + * + * @return This builder. + */ + public Builder dateStart(String dateStart) { + this.dateStart = dateStart; + return this; + } + + /** + * End date for the report in ISO-8601 format (e.g. {@code 2024-02-07T14:22:08+00:00}). + * Defaults to the current time if not specified. + * + * @param dateEnd The end date string. + * + * @return This builder. + */ + public Builder dateEnd(String dateEnd) { + this.dateEnd = dateEnd; + return this; + } + + /** + * Whether to include sub-account data in the report. + * + * @param includeSubaccounts {@code true} to include sub-account data. + * + * @return This builder. + */ + public Builder includeSubaccounts(boolean includeSubaccounts) { + this.includeSubaccounts = includeSubaccounts; + return this; + } + + /** + * Webhook URL to receive a notification when the report is ready. + * + * @param callbackUrl The callback URL. + * + * @return This builder. + */ + public Builder callbackUrl(URI callbackUrl) { + this.callbackUrl = callbackUrl; + return this; + } + + /** + * Direction filter: {@code inbound} or {@code outbound}. + * Required for SMS and MESSAGES products. + * + * @param direction The direction string. + * + * @return This builder. + */ + public Builder direction(String direction) { + this.direction = direction; + return this; + } + + /** + * Status filter for the records. + * + * @param status The status string. + * + * @return This builder. + */ + public Builder status(String status) { + this.status = status; + return this; + } + + /** + * Sender ID or phone number filter. + * + * @param from The sender filter string. + * + * @return This builder. + */ + public Builder from(String from) { + this.from = from; + return this; + } + + /** + * Recipient phone number filter. + * + * @param to The recipient filter string. + * + * @return This builder. + */ + public Builder to(String to) { + this.to = to; + return this; + } + + /** + * ISO two-letter country code filter. + * + * @param country The country code. + * + * @return This builder. + */ + public Builder country(String country) { + this.country = country; + return this; + } + + /** + * Mobile network code filter. + * + * @param network The network code. + * + * @return This builder. + */ + public Builder network(String network) { + this.network = network; + return this; + } + + /** + * Custom client reference filter (SMS). + * + * @param clientRef The client reference string. + * + * @return This builder. + */ + public Builder clientRef(String clientRef) { + this.clientRef = clientRef; + return this; + } + + /** + * Account reference filter (SMS). + * + * @param accountRef The account reference string. + * + * @return This builder. + */ + public Builder accountRef(String accountRef) { + this.accountRef = accountRef; + return this; + } + + /** + * Whether to include message body content in the report (SMS, MESSAGES). + * + * @param includeMessage {@code true} to include message content. + * + * @return This builder. + */ + public Builder includeMessage(boolean includeMessage) { + this.includeMessage = includeMessage; + return this; + } + + /** + * Whether to include concatenation info in the report (SMS outbound only). + * + * @param showConcatenated {@code true} to include concatenation info. + * + * @return This builder. + */ + public Builder showConcatenated(boolean showConcatenated) { + this.showConcatenated = showConcatenated; + return this; + } + + /** + * Call identifier filter (VOICE-CALL, VOICE-FAILED, ASR, WEBSOCKET-CALL, AMD). + * + * @param callId The call ID. + * + * @return This builder. + */ + public Builder callId(String callId) { + this.callId = callId; + return this; + } + + /** + * Conversation ID filter (IN-APP-VOICE, CONVERSATION-EVENT, CONVERSATION-MESSAGE). + * + * @param conversationId The conversation ID. + * + * @return This builder. + */ + public Builder conversationId(String conversationId) { + this.conversationId = conversationId; + return this; + } + + /** + * Leg ID filter (IN-APP-VOICE). + * + * @param legId The leg ID. + * + * @return This builder. + */ + public Builder legId(String legId) { + this.legId = legId; + return this; + } + + /** + * Messaging provider filter (MESSAGES). + * Valid values: {@code whatsapp}, {@code sms}, {@code mms}, {@code messenger}, + * {@code viber_service_msg}, {@code instagram}, {@code rcs}. + * + * @param provider The provider string. + * + * @return This builder. + */ + public Builder provider(String provider) { + this.provider = provider; + return this; + } + + /** + * Verification channel filter (VERIFY-V2). + * Valid values: {@code v2}, {@code email}, {@code silent_auth}. + * + * @param channel The channel string. + * + * @return This builder. + */ + public Builder channel(String channel) { + this.channel = channel; + return this; + } + + /** + * Parent request ID filter to correlate v2 verification events (VERIFY-V2). + * + * @param parentRequestId The parent request ID. + * + * @return This builder. + */ + public Builder parentRequestId(String parentRequestId) { + this.parentRequestId = parentRequestId; + return this; + } + + /** + * Language/locale filter (VERIFY-V2). + * + * @param locale The locale string. + * + * @return This builder. + */ + public Builder locale(String locale) { + this.locale = locale; + return this; + } + + /** + * Phone number filter for Number Insight reports. + * + * @param number The phone number. + * + * @return This builder. + */ + public Builder number(String number) { + this.number = number; + return this; + } + + /** + * Product name filter for Network API events (NETWORK-API-EVENT). + * + * @param productName The product name (e.g. {@code camara-sim-swap}). + * + * @return This builder. + */ + public Builder productName(String productName) { + this.productName = productName; + return this; + } + + /** + * Request type filter for Network API events (NETWORK-API-EVENT). + * + * @param requestType The request type. + * + * @return This builder. + */ + public Builder requestType(String requestType) { + this.requestType = requestType; + return this; + } + + /** + * Session ID filter for Network API events (NETWORK-API-EVENT). + * + * @param requestSessionId The session ID. + * + * @return This builder. + */ + public Builder requestSessionId(String requestSessionId) { + this.requestSessionId = requestSessionId; + return this; + } + + /** + * API path filter for Network API events (NETWORK-API-EVENT). + * + * @param productPath The product path. + * + * @return This builder. + */ + public Builder productPath(String productPath) { + this.productPath = productPath; + return this; + } + + /** + * Correlation ID filter for Network API events (NETWORK-API-EVENT). + * + * @param correlationId The correlation ID. + * + * @return This builder. + */ + public Builder correlationId(String correlationId) { + this.correlationId = correlationId; + return this; + } + + /** + * Video session ID filter (VIDEO-API). + * + * @param sessionId The session ID. + * + * @return This builder. + */ + public Builder sessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Meeting ID filter (VIDEO-API). + * + * @param meetingId The meeting ID. + * + * @return This builder. + */ + public Builder meetingId(String meetingId) { + this.meetingId = meetingId; + return this; + } + + /** + * Builds the {@link AsyncReportRequest}. + * + * @return A new AsyncReportRequest instance. + */ + public AsyncReportRequest build() { + return new AsyncReportRequest(this); + } + } +} diff --git a/src/main/java/com/vonage/client/reports/Product.java b/src/main/java/com/vonage/client/reports/Product.java new file mode 100644 index 000000000..0d8738d9e --- /dev/null +++ b/src/main/java/com/vonage/client/reports/Product.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.reports; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Represents a Vonage product type for Reports API requests. + */ +public enum Product { + SMS("SMS"), + SMS_TRAFFIC_CONTROL("SMS-TRAFFIC-CONTROL"), + VOICE_CALL("VOICE-CALL"), + VOICE_FAILED("VOICE-FAILED"), + VOICE_TTS("VOICE-TTS"), + IN_APP_VOICE("IN-APP-VOICE"), + WEBSOCKET_CALL("WEBSOCKET-CALL"), + ASR("ASR"), + AMD("AMD"), + VERIFY_API("VERIFY-API"), + VERIFY_V2("VERIFY-V2"), + NUMBER_INSIGHT("NUMBER-INSIGHT"), + CONVERSATION_EVENT("CONVERSATION-EVENT"), + CONVERSATION_MESSAGE("CONVERSATION-MESSAGE"), + MESSAGES("MESSAGES"), + VIDEO_API("VIDEO-API"), + NETWORK_API_EVENT("NETWORK-API-EVENT"), + REPORTS_USAGE("REPORTS-USAGE"); + + private final String value; + + Product(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static Product fromValue(String value) { + if (value == null) return null; + for (Product p : Product.values()) { + if (p.value.equalsIgnoreCase(value)) return p; + } + return null; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/com/vonage/client/reports/RecordsFilter.java b/src/main/java/com/vonage/client/reports/RecordsFilter.java new file mode 100644 index 000000000..77180bbaf --- /dev/null +++ b/src/main/java/com/vonage/client/reports/RecordsFilter.java @@ -0,0 +1,885 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.reports; + +import com.vonage.client.AbstractQueryParamsRequest; +import java.util.Map; +import java.util.Objects; + +/** + * Query parameters for synchronously retrieving report records ({@code GET /v2/reports/records}). + *

+ * Build using {@link #builder(Product, String)}, providing the required {@code product} and + * {@code account_id}. Use either date-based queries ({@link Builder#dateStart}/{@link Builder#dateEnd}) + * or ID-based queries ({@link Builder#id}) — when using ID queries, only {@code product}, + * {@code account_id}, {@code direction}, {@code id}, {@code includeMessage} and + * {@code showConcatenated} are allowed. + *

+ */ +public class RecordsFilter extends AbstractQueryParamsRequest { + + private final Product product; + private final String accountId; + private final String dateStart, dateEnd, cursor, iv, id, direction, status; + private final String from, to, country, network, clientRef, accountRef; + private final String callId, conversationId, legId, provider, channel, parentRequestId, locale; + private final String number, productName, requestType, requestSessionId, productPath, correlationId; + private final String sessionId, meetingId, numberType, risk; + private final Boolean includeMessage, showConcatenated, swapped; + + private RecordsFilter(Builder builder) { + product = Objects.requireNonNull(builder.product, "Product is required."); + accountId = Objects.requireNonNull(builder.accountId, "Account ID is required."); + if (accountId.trim().isEmpty()) { + throw new IllegalArgumentException("Account ID cannot be empty."); + } + dateStart = builder.dateStart; + dateEnd = builder.dateEnd; + cursor = builder.cursor; + iv = builder.iv; + id = builder.id; + direction = builder.direction; + status = builder.status; + from = builder.from; + to = builder.to; + country = builder.country; + network = builder.network; + clientRef = builder.clientRef; + accountRef = builder.accountRef; + includeMessage = builder.includeMessage; + showConcatenated = builder.showConcatenated; + callId = builder.callId; + conversationId = builder.conversationId; + legId = builder.legId; + provider = builder.provider; + channel = builder.channel; + parentRequestId = builder.parentRequestId; + locale = builder.locale; + number = builder.number; + numberType = builder.numberType; + risk = builder.risk; + swapped = builder.swapped; + productName = builder.productName; + requestType = builder.requestType; + requestSessionId = builder.requestSessionId; + productPath = builder.productPath; + correlationId = builder.correlationId; + sessionId = builder.sessionId; + meetingId = builder.meetingId; + } + + @Override + public Map makeParams() { + Map params = super.makeParams(); + conditionalAdd("product", product); + conditionalAdd("account_id", accountId); + conditionalAdd("date_start", dateStart); + conditionalAdd("date_end", dateEnd); + conditionalAdd("cursor", cursor); + conditionalAdd("iv", iv); + conditionalAdd("id", id); + conditionalAdd("direction", direction); + conditionalAdd("status", status); + conditionalAdd("from", from); + conditionalAdd("to", to); + conditionalAdd("country", country); + conditionalAdd("network", network); + conditionalAdd("client_ref", clientRef); + conditionalAdd("account_ref", accountRef); + conditionalAdd("include_message", includeMessage); + conditionalAdd("show_concatenated", showConcatenated); + conditionalAdd("call_id", callId); + conditionalAdd("conversation_id", conversationId); + conditionalAdd("leg_id", legId); + conditionalAdd("provider", provider); + conditionalAdd("channel", channel); + conditionalAdd("parent_request_id", parentRequestId); + conditionalAdd("locale", locale); + conditionalAdd("number", number); + conditionalAdd("number_type", numberType); + conditionalAdd("risk", risk); + conditionalAdd("swapped", swapped); + conditionalAdd("product_name", productName); + conditionalAdd("request_type", requestType); + conditionalAdd("request_session_id", requestSessionId); + conditionalAdd("product_path", productPath); + conditionalAdd("correlation_id", correlationId); + conditionalAdd("session_id", sessionId); + conditionalAdd("meeting_id", meetingId); + return params; + } + + /** + * The product type for this records query. + * + * @return The product enum value. + */ + public Product getProduct() { + return product; + } + + /** + * The account ID (API key) being queried. + * + * @return The account ID string. + */ + public String getAccountId() { + return accountId; + } + + /** + * Start date for the query in ISO-8601 format. + * + * @return The start date string, or {@code null} if not set. + */ + public String getDateStart() { + return dateStart; + } + + /** + * End date for the query in ISO-8601 format. + * + * @return The end date string, or {@code null} if not set. + */ + public String getDateEnd() { + return dateEnd; + } + + /** + * Pagination cursor for retrieving the next page of results (date-based queries only). + * + * @return The cursor string, or {@code null} if not set. + */ + public String getCursor() { + return cursor; + } + + /** + * Initialization vector for encrypted cursor pagination (date-based queries only). + * + * @return The IV string, or {@code null} if not set. + */ + public String getIv() { + return iv; + } + + /** + * UUID(s) of specific messages or calls to retrieve (comma-separated, maximum 20). + * + * @return The ID string, or {@code null} if not set. + */ + public String getId() { + return id; + } + + /** + * Direction filter: {@code inbound} or {@code outbound}. + * + * @return The direction string, or {@code null} if not set. + */ + public String getDirection() { + return direction; + } + + /** + * Status filter for the records. + * + * @return The status string, or {@code null} if not set. + */ + public String getStatus() { + return status; + } + + /** + * Sender ID or phone number filter. + * + * @return The sender filter string, or {@code null} if not set. + */ + public String getFrom() { + return from; + } + + /** + * Recipient phone number filter. + * + * @return The recipient filter string, or {@code null} if not set. + */ + public String getTo() { + return to; + } + + /** + * ISO two-letter country code filter. + * + * @return The country code, or {@code null} if not set. + */ + public String getCountry() { + return country; + } + + /** + * Mobile network code filter. + * + * @return The network code, or {@code null} if not set. + */ + public String getNetwork() { + return network; + } + + /** + * Custom client reference filter (SMS). + * + * @return The client reference string, or {@code null} if not set. + */ + public String getClientRef() { + return clientRef; + } + + /** + * Account reference filter (SMS). + * + * @return The account reference string, or {@code null} if not set. + */ + public String getAccountRef() { + return accountRef; + } + + /** + * Whether to include message body content in the report. + * + * @return {@code true} if message content is included, or {@code null} if not set. + */ + public Boolean getIncludeMessage() { + return includeMessage; + } + + /** + * Whether to include concatenation info in the report (SMS outbound only). + * + * @return {@code true} if concatenation info is included, or {@code null} if not set. + */ + public Boolean getShowConcatenated() { + return showConcatenated; + } + + /** + * Call identifier filter (VOICE-CALL, VOICE-FAILED, ASR, WEBSOCKET-CALL, AMD). + * + * @return The call ID, or {@code null} if not set. + */ + public String getCallId() { + return callId; + } + + /** + * Conversation ID filter (IN-APP-VOICE, CONVERSATION-EVENT, CONVERSATION-MESSAGE). + * + * @return The conversation ID, or {@code null} if not set. + */ + public String getConversationId() { + return conversationId; + } + + /** + * Leg ID filter (IN-APP-VOICE). + * + * @return The leg ID, or {@code null} if not set. + */ + public String getLegId() { + return legId; + } + + /** + * Messaging provider filter (MESSAGES). + * + * @return The provider string, or {@code null} if not set. + */ + public String getProvider() { + return provider; + } + + /** + * Verification channel filter (VERIFY-V2). + * + * @return The channel string, or {@code null} if not set. + */ + public String getChannel() { + return channel; + } + + /** + * Parent request ID filter (VERIFY-V2). + * + * @return The parent request ID, or {@code null} if not set. + */ + public String getParentRequestId() { + return parentRequestId; + } + + /** + * Language/locale filter (VERIFY-V2). + * + * @return The locale string, or {@code null} if not set. + */ + public String getLocale() { + return locale; + } + + /** + * Phone number filter (NUMBER-INSIGHT). + * + * @return The phone number, or {@code null} if not set. + */ + public String getNumber() { + return number; + } + + /** + * Number type filter (VERIFY-API). + * + * @return The number type, or {@code null} if not set. + */ + public String getNumberType() { + return numberType; + } + + /** + * Risk assessment level filter. + * + * @return The risk level, or {@code null} if not set. + */ + public String getRisk() { + return risk; + } + + /** + * Whether the number has been ported/swapped filter. + * + * @return {@code true} if filtering for swapped numbers, or {@code null} if not set. + */ + public Boolean getSwapped() { + return swapped; + } + + /** + * Product name for Network API event filter (NETWORK-API-EVENT). + * + * @return The product name, or {@code null} if not set. + */ + public String getProductName() { + return productName; + } + + /** + * Request type filter for Network API events (NETWORK-API-EVENT). + * + * @return The request type, or {@code null} if not set. + */ + public String getRequestType() { + return requestType; + } + + /** + * Session ID filter for Network API events (NETWORK-API-EVENT). + * + * @return The request session ID, or {@code null} if not set. + */ + public String getRequestSessionId() { + return requestSessionId; + } + + /** + * API path filter for Network API events (NETWORK-API-EVENT). + * + * @return The product path, or {@code null} if not set. + */ + public String getProductPath() { + return productPath; + } + + /** + * Correlation ID filter for Network API events (NETWORK-API-EVENT). + * + * @return The correlation ID, or {@code null} if not set. + */ + public String getCorrelationId() { + return correlationId; + } + + /** + * Video session ID filter (VIDEO-API). + * + * @return The session ID, or {@code null} if not set. + */ + public String getSessionId() { + return sessionId; + } + + /** + * Meeting ID filter (VIDEO-API). + * + * @return The meeting ID, or {@code null} if not set. + */ + public String getMeetingId() { + return meetingId; + } + + /** + * Entry point for constructing an instance of this class. + * + * @param product The product type to retrieve records for. + * @param accountId The account ID (API key) to query. + * + * @return A new Builder. + */ + public static Builder builder(Product product, String accountId) { + return new Builder(product, accountId); + } + + /** + * Builder for {@link RecordsFilter}. + */ + public static class Builder { + private final Product product; + private final String accountId; + private String dateStart, dateEnd, cursor, iv, id, direction, status; + private String from, to, country, network, clientRef, accountRef; + private String callId, conversationId, legId, provider, channel, parentRequestId, locale; + private String number, numberType, risk; + private String productName, requestType, requestSessionId, productPath, correlationId; + private String sessionId, meetingId; + private Boolean includeMessage, showConcatenated, swapped; + + private Builder(Product product, String accountId) { + this.product = product; + this.accountId = accountId; + } + + /** + * Start date for the query in ISO-8601 format (e.g. {@code 2024-02-02T13:50:00+00:00}). + * Defaults to seven days ago if not specified. + * + * @param dateStart The start date string. + * + * @return This builder. + */ + public Builder dateStart(String dateStart) { + this.dateStart = dateStart; + return this; + } + + /** + * End date for the query in ISO-8601 format (e.g. {@code 2024-02-07T14:22:08+00:00}). + * Defaults to the current time if not specified. + * + * @param dateEnd The end date string. + * + * @return This builder. + */ + public Builder dateEnd(String dateEnd) { + this.dateEnd = dateEnd; + return this; + } + + /** + * Pagination cursor for retrieving the next page of results (date-based queries only). + * + * @param cursor The cursor from a previous response. + * + * @return This builder. + */ + public Builder cursor(String cursor) { + this.cursor = cursor; + return this; + } + + /** + * Initialization vector for encrypted cursor pagination (date-based queries only). + * + * @param iv The IV from a previous response. + * + * @return This builder. + */ + public Builder iv(String iv) { + this.iv = iv; + return this; + } + + /** + * UUID(s) of specific messages or calls to retrieve. + * You can specify a comma-separated list of up to 20 UUIDs. + * When set, only {@code product}, {@code account_id}, {@code direction}, + * {@code include_message} and {@code show_concatenated} are also applicable. + * + * @param id The UUID or comma-separated list of UUIDs. + * + * @return This builder. + */ + public Builder id(String id) { + this.id = id; + return this; + } + + /** + * Direction filter: {@code inbound} or {@code outbound}. + * Required for SMS and MESSAGES products. + * + * @param direction The direction string. + * + * @return This builder. + */ + public Builder direction(String direction) { + this.direction = direction; + return this; + } + + /** + * Status filter for the records. + * + * @param status The status string. + * + * @return This builder. + */ + public Builder status(String status) { + this.status = status; + return this; + } + + /** + * Sender ID or phone number filter. + * + * @param from The sender filter string. + * + * @return This builder. + */ + public Builder from(String from) { + this.from = from; + return this; + } + + /** + * Recipient phone number filter. + * + * @param to The recipient filter string. + * + * @return This builder. + */ + public Builder to(String to) { + this.to = to; + return this; + } + + /** + * ISO two-letter country code filter. + * + * @param country The country code. + * + * @return This builder. + */ + public Builder country(String country) { + this.country = country; + return this; + } + + /** + * Mobile network code filter. + * + * @param network The network code. + * + * @return This builder. + */ + public Builder network(String network) { + this.network = network; + return this; + } + + /** + * Custom client reference filter (SMS). + * + * @param clientRef The client reference string. + * + * @return This builder. + */ + public Builder clientRef(String clientRef) { + this.clientRef = clientRef; + return this; + } + + /** + * Account reference filter (SMS). + * + * @param accountRef The account reference string. + * + * @return This builder. + */ + public Builder accountRef(String accountRef) { + this.accountRef = accountRef; + return this; + } + + /** + * Whether to include message body content in the report (SMS, MESSAGES). + * + * @param includeMessage {@code true} to include message content. + * + * @return This builder. + */ + public Builder includeMessage(boolean includeMessage) { + this.includeMessage = includeMessage; + return this; + } + + /** + * Whether to include concatenation info in the report (SMS outbound only). + * + * @param showConcatenated {@code true} to include concatenation info. + * + * @return This builder. + */ + public Builder showConcatenated(boolean showConcatenated) { + this.showConcatenated = showConcatenated; + return this; + } + + /** + * Call identifier filter (VOICE-CALL, VOICE-FAILED, ASR, WEBSOCKET-CALL, AMD). + * + * @param callId The call ID. + * + * @return This builder. + */ + public Builder callId(String callId) { + this.callId = callId; + return this; + } + + /** + * Conversation ID filter (IN-APP-VOICE, CONVERSATION-EVENT, CONVERSATION-MESSAGE). + * + * @param conversationId The conversation ID. + * + * @return This builder. + */ + public Builder conversationId(String conversationId) { + this.conversationId = conversationId; + return this; + } + + /** + * Leg ID filter (IN-APP-VOICE). + * + * @param legId The leg ID. + * + * @return This builder. + */ + public Builder legId(String legId) { + this.legId = legId; + return this; + } + + /** + * Messaging provider filter (MESSAGES). + * Valid values: {@code whatsapp}, {@code sms}, {@code mms}, {@code messenger}, + * {@code viber_service_msg}, {@code instagram}, {@code rcs}. + * + * @param provider The provider string. + * + * @return This builder. + */ + public Builder provider(String provider) { + this.provider = provider; + return this; + } + + /** + * Verification channel filter (VERIFY-V2). + * Valid values: {@code v2}, {@code email}, {@code silent_auth}. + * + * @param channel The channel string. + * + * @return This builder. + */ + public Builder channel(String channel) { + this.channel = channel; + return this; + } + + /** + * Parent request ID filter (VERIFY-V2). + * + * @param parentRequestId The parent request ID. + * + * @return This builder. + */ + public Builder parentRequestId(String parentRequestId) { + this.parentRequestId = parentRequestId; + return this; + } + + /** + * Language/locale filter (VERIFY-V2). + * + * @param locale The locale string. + * + * @return This builder. + */ + public Builder locale(String locale) { + this.locale = locale; + return this; + } + + /** + * Phone number filter (NUMBER-INSIGHT). + * + * @param number The phone number. + * + * @return This builder. + */ + public Builder number(String number) { + this.number = number; + return this; + } + + /** + * Number type filter (VERIFY-API). + * + * @param numberType The number type. + * + * @return This builder. + */ + public Builder numberType(String numberType) { + this.numberType = numberType; + return this; + } + + /** + * Risk assessment level filter. + * + * @param risk The risk level. + * + * @return This builder. + */ + public Builder risk(String risk) { + this.risk = risk; + return this; + } + + /** + * Filter for ported/swapped numbers. + * + * @param swapped {@code true} to filter for swapped numbers. + * + * @return This builder. + */ + public Builder swapped(boolean swapped) { + this.swapped = swapped; + return this; + } + + /** + * Product name filter for Network API events (NETWORK-API-EVENT). + * + * @param productName The product name (e.g. {@code camara-sim-swap}). + * + * @return This builder. + */ + public Builder productName(String productName) { + this.productName = productName; + return this; + } + + /** + * Request type filter for Network API events (NETWORK-API-EVENT). + * + * @param requestType The request type. + * + * @return This builder. + */ + public Builder requestType(String requestType) { + this.requestType = requestType; + return this; + } + + /** + * Session ID filter for Network API events (NETWORK-API-EVENT). + * + * @param requestSessionId The session ID. + * + * @return This builder. + */ + public Builder requestSessionId(String requestSessionId) { + this.requestSessionId = requestSessionId; + return this; + } + + /** + * API path filter for Network API events (NETWORK-API-EVENT). + * + * @param productPath The product path. + * + * @return This builder. + */ + public Builder productPath(String productPath) { + this.productPath = productPath; + return this; + } + + /** + * Correlation ID filter for Network API events (NETWORK-API-EVENT). + * + * @param correlationId The correlation ID. + * + * @return This builder. + */ + public Builder correlationId(String correlationId) { + this.correlationId = correlationId; + return this; + } + + /** + * Video session ID filter (VIDEO-API). + * + * @param sessionId The session ID. + * + * @return This builder. + */ + public Builder sessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Meeting ID filter (VIDEO-API). + * + * @param meetingId The meeting ID. + * + * @return This builder. + */ + public Builder meetingId(String meetingId) { + this.meetingId = meetingId; + return this; + } + + /** + * Builds the {@link RecordsFilter}. + * + * @return A new RecordsFilter instance. + */ + public RecordsFilter build() { + return new RecordsFilter(this); + } + } +} diff --git a/src/main/java/com/vonage/client/reports/RecordsResponse.java b/src/main/java/com/vonage/client/reports/RecordsResponse.java new file mode 100644 index 000000000..681129c50 --- /dev/null +++ b/src/main/java/com/vonage/client/reports/RecordsResponse.java @@ -0,0 +1,158 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.reports; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.vonage.client.JsonableBaseObject; +import java.util.List; +import java.util.Map; + +/** + * Response for synchronous record retrieval ({@code GET /v2/reports/records}). + * Contains pagination metadata and the list of records for the requested product. + *

+ * Record fields vary by product type; use {@link #getRecords()} to access the raw field maps. + *

+ */ +public class RecordsResponse extends JsonableBaseObject { + + @JsonProperty("request_id") + private String requestId; + + @JsonProperty("request_status") + private ReportStatus requestStatus; + + @JsonProperty("received_at") + private String receivedAt; + + @JsonProperty("items_count") + private Long itemsCount; + + @JsonProperty("cursor") + private String cursor; + + @JsonProperty("iv") + private String iv; + + @JsonProperty("ids_not_found") + private String idsNotFound; + + @JsonProperty("product") + private Product product; + + @JsonProperty("_links") + private Map> links; + + @JsonProperty("records") + private List> records; + + RecordsResponse() {} + + /** + * Unique ID associated with this synchronous request. + * + * @return The request ID, or {@code null} if not available. + */ + public String getRequestId() { + return requestId; + } + + /** + * Result status of the synchronous request. + * Either {@link ReportStatus#SUCCESS} or {@link ReportStatus#TRUNCATED}. + * + * @return The request status, or {@code null} if not available. + */ + public ReportStatus getRequestStatus() { + return requestStatus; + } + + /** + * Timestamp when the request was processed by the Reports API. + * + * @return The received-at timestamp string, or {@code null} if not available. + */ + public String getReceivedAt() { + return receivedAt; + } + + /** + * The number of records returned in this page/response. + * + * @return The items count, or {@code null} if not available. + */ + public Long getItemsCount() { + return itemsCount; + } + + /** + * Cursor for paginating to the next page of results (present only if more records exist). + * + * @return The pagination cursor, or {@code null} if there are no more pages. + */ + public String getCursor() { + return cursor; + } + + /** + * Initialization vector for cursor processing (present only if pagination is applicable). + * + * @return The IV string, or {@code null} if pagination is not applicable. + */ + public String getIv() { + return iv; + } + + /** + * Comma-separated list of IDs that were not found when using ID-based queries. + * + * @return The not-found IDs string, or {@code null} if all IDs were found. + */ + public String getIdsNotFound() { + return idsNotFound; + } + + /** + * The product type for the records in this response. + * + * @return The product enum value, or {@code null} if not available. + */ + public Product getProduct() { + return product; + } + + /** + * HAL links for navigation. May contain {@code self} and {@code next} links. + * The {@code next} link is only present if more records are available. + * + * @return The links map, or {@code null} if not available. + */ + public Map> getLinks() { + return links; + } + + /** + * The records returned by this request. Each record is a map of field names to values. + * The available fields depend on the product type — refer to the + * Reports API documentation + * for details on each product's record schema. + * + * @return The list of record maps, or {@code null} if no records were returned. + */ + public List> getRecords() { + return records; + } +} diff --git a/src/main/java/com/vonage/client/reports/ReportResponse.java b/src/main/java/com/vonage/client/reports/ReportResponse.java new file mode 100644 index 000000000..bafa43d73 --- /dev/null +++ b/src/main/java/com/vonage/client/reports/ReportResponse.java @@ -0,0 +1,489 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.reports; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.vonage.client.JsonableBaseObject; +import java.net.URI; +import java.util.Map; + +/** + * Response for asynchronous report operations (create, get status, cancel). + * Contains the report's current status along with the original request parameters. + */ +public class ReportResponse extends JsonableBaseObject { + + @JsonProperty("request_id") + private String requestId; + + @JsonProperty("request_status") + private ReportStatus requestStatus; + + @JsonProperty("receive_time") + private String receiveTime; + + @JsonProperty("start_time") + private String startTime; + + @JsonProperty("items_count") + private Long itemsCount; + + @JsonProperty("_links") + private Map> links; + + // Original request fields returned in the response + @JsonProperty("product") + private Product product; + + @JsonProperty("account_id") + private String accountId; + + @JsonProperty("date_start") + private String dateStart; + + @JsonProperty("date_end") + private String dateEnd; + + @JsonProperty("include_subaccounts") + private Boolean includeSubaccounts; + + @JsonProperty("callback_url") + private URI callbackUrl; + + @JsonProperty("direction") + private String direction; + + @JsonProperty("status") + private String status; + + @JsonProperty("from") + private String from; + + @JsonProperty("to") + private String to; + + @JsonProperty("country") + private String country; + + @JsonProperty("network") + private String network; + + @JsonProperty("client_ref") + private String clientRef; + + @JsonProperty("account_ref") + private String accountRef; + + @JsonProperty("include_message") + private Boolean includeMessage; + + @JsonProperty("show_concatenated") + private Boolean showConcatenated; + + @JsonProperty("call_id") + private String callId; + + @JsonProperty("conversation_id") + private String conversationId; + + @JsonProperty("leg_id") + private String legId; + + @JsonProperty("provider") + private String provider; + + @JsonProperty("channel") + private String channel; + + @JsonProperty("parent_request_id") + private String parentRequestId; + + @JsonProperty("locale") + private String locale; + + @JsonProperty("number") + private String number; + + @JsonProperty("product_name") + private String productName; + + @JsonProperty("request_type") + private String requestType; + + @JsonProperty("request_session_id") + private String requestSessionId; + + @JsonProperty("product_path") + private String productPath; + + @JsonProperty("correlation_id") + private String correlationId; + + @JsonProperty("session_id") + private String sessionId; + + @JsonProperty("meeting_id") + private String meetingId; + + ReportResponse() {} + + /** + * UUID of the report request. + * + * @return The request ID string, or {@code null} if not available. + */ + public String getRequestId() { + return requestId; + } + + /** + * Current processing status of the report. + * + * @return The report status enum value, or {@code null} if not available. + */ + public ReportStatus getRequestStatus() { + return requestStatus; + } + + /** + * Time at which the report request was received by Vonage. + * + * @return The receive time string, or {@code null} if not available. + */ + public String getReceiveTime() { + return receiveTime; + } + + /** + * Time at which processing of the report started. + * + * @return The start time string, or {@code null} if not available. + */ + public String getStartTime() { + return startTime; + } + + /** + * Total number of records in the report. + * + * @return The items count, or {@code null} if not available. + */ + public Long getItemsCount() { + return itemsCount; + } + + /** + * HAL links for the report. Typically contains {@code self} and {@code download_report} links. + * The {@code download_report} link contains the URL to download the report file. + * + * @return The links map, or {@code null} if not available. + */ + public Map> getLinks() { + return links; + } + + /** + * Convenience method to get the download URL for the report. + * Returns the {@code href} value from the {@code download_report} HAL link. + * + * @return The download URL string, or {@code null} if not available. + */ + public String getDownloadUrl() { + if (links == null) return null; + Map downloadReport = links.get("download_report"); + if (downloadReport == null) return null; + return downloadReport.get("href"); + } + + /** + * The product type for this report. + * + * @return The product enum value, or {@code null} if not available. + */ + public Product getProduct() { + return product; + } + + /** + * The account ID (API key) this report was created for. + * + * @return The account ID string, or {@code null} if not available. + */ + public String getAccountId() { + return accountId; + } + + /** + * Start date of the report range. + * + * @return The start date string, or {@code null} if not set. + */ + public String getDateStart() { + return dateStart; + } + + /** + * End date of the report range. + * + * @return The end date string, or {@code null} if not set. + */ + public String getDateEnd() { + return dateEnd; + } + + /** + * Whether sub-account data was included in this report. + * + * @return {@code true} if sub-accounts were included, or {@code null} if not set. + */ + public Boolean getIncludeSubaccounts() { + return includeSubaccounts; + } + + /** + * Webhook URL for report completion notifications. + * + * @return The callback URL, or {@code null} if not set. + */ + public URI getCallbackUrl() { + return callbackUrl; + } + + /** + * Direction filter applied to this report. + * + * @return The direction string, or {@code null} if not set. + */ + public String getDirection() { + return direction; + } + + /** + * Status filter applied to this report. + * + * @return The status string, or {@code null} if not set. + */ + public String getStatus() { + return status; + } + + /** + * Sender filter applied to this report. + * + * @return The sender filter string, or {@code null} if not set. + */ + public String getFrom() { + return from; + } + + /** + * Recipient filter applied to this report. + * + * @return The recipient filter string, or {@code null} if not set. + */ + public String getTo() { + return to; + } + + /** + * Country filter applied to this report. + * + * @return The country code, or {@code null} if not set. + */ + public String getCountry() { + return country; + } + + /** + * Network filter applied to this report. + * + * @return The network code, or {@code null} if not set. + */ + public String getNetwork() { + return network; + } + + /** + * Client reference filter applied to this report. + * + * @return The client reference string, or {@code null} if not set. + */ + public String getClientRef() { + return clientRef; + } + + /** + * Account reference filter applied to this report. + * + * @return The account reference string, or {@code null} if not set. + */ + public String getAccountRef() { + return accountRef; + } + + /** + * Whether message body content was included in this report. + * + * @return {@code true} if message content was included, or {@code null} if not set. + */ + public Boolean getIncludeMessage() { + return includeMessage; + } + + /** + * Whether concatenation info was included in this report. + * + * @return {@code true} if concatenation info was included, or {@code null} if not set. + */ + public Boolean getShowConcatenated() { + return showConcatenated; + } + + /** + * Call identifier filter applied to this report. + * + * @return The call ID, or {@code null} if not set. + */ + public String getCallId() { + return callId; + } + + /** + * Conversation ID filter applied to this report. + * + * @return The conversation ID, or {@code null} if not set. + */ + public String getConversationId() { + return conversationId; + } + + /** + * Leg ID filter applied to this report. + * + * @return The leg ID, or {@code null} if not set. + */ + public String getLegId() { + return legId; + } + + /** + * Messaging provider filter applied to this report. + * + * @return The provider string, or {@code null} if not set. + */ + public String getProvider() { + return provider; + } + + /** + * Verification channel filter applied to this report. + * + * @return The channel string, or {@code null} if not set. + */ + public String getChannel() { + return channel; + } + + /** + * Parent request ID filter applied to this report. + * + * @return The parent request ID, or {@code null} if not set. + */ + public String getParentRequestId() { + return parentRequestId; + } + + /** + * Locale filter applied to this report. + * + * @return The locale string, or {@code null} if not set. + */ + public String getLocale() { + return locale; + } + + /** + * Phone number filter applied to this report. + * + * @return The phone number, or {@code null} if not set. + */ + public String getNumber() { + return number; + } + + /** + * Product name filter applied to this report. + * + * @return The product name, or {@code null} if not set. + */ + public String getProductName() { + return productName; + } + + /** + * Request type filter applied to this report. + * + * @return The request type, or {@code null} if not set. + */ + public String getRequestType() { + return requestType; + } + + /** + * Request session ID filter applied to this report. + * + * @return The request session ID, or {@code null} if not set. + */ + public String getRequestSessionId() { + return requestSessionId; + } + + /** + * Product path filter applied to this report. + * + * @return The product path, or {@code null} if not set. + */ + public String getProductPath() { + return productPath; + } + + /** + * Correlation ID filter applied to this report. + * + * @return The correlation ID, or {@code null} if not set. + */ + public String getCorrelationId() { + return correlationId; + } + + /** + * Video session ID filter applied to this report. + * + * @return The session ID, or {@code null} if not set. + */ + public String getSessionId() { + return sessionId; + } + + /** + * Meeting ID filter applied to this report. + * + * @return The meeting ID, or {@code null} if not set. + */ + public String getMeetingId() { + return meetingId; + } +} diff --git a/src/main/java/com/vonage/client/reports/ReportStatus.java b/src/main/java/com/vonage/client/reports/ReportStatus.java new file mode 100644 index 000000000..8cd04266b --- /dev/null +++ b/src/main/java/com/vonage/client/reports/ReportStatus.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.reports; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Represents the processing status of a report request. + */ +public enum ReportStatus { + PENDING, + PROCESSING, + SUCCESS, + ABORTED, + FAILED, + TRUNCATED; + + @JsonValue + public String getValue() { + return name(); + } + + @JsonCreator + public static ReportStatus fromValue(String value) { + if (value == null) return null; + try { + return valueOf(value.toUpperCase()); + } + catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/com/vonage/client/reports/ReportsClient.java b/src/main/java/com/vonage/client/reports/ReportsClient.java new file mode 100644 index 000000000..9173d2633 --- /dev/null +++ b/src/main/java/com/vonage/client/reports/ReportsClient.java @@ -0,0 +1,205 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.reports; + +import com.vonage.client.DynamicEndpoint; +import com.vonage.client.HttpWrapper; +import com.vonage.client.RestEndpoint; +import com.vonage.client.VonageClient; +import com.vonage.client.auth.ApiKeyHeaderAuthMethod; +import com.vonage.client.common.HttpMethod; +import java.util.Objects; +import java.util.function.Function; + +/** + * A client for communicating with the Vonage Reports API. + * The standard way to obtain an instance of this class is to use + * {@link VonageClient#getReportsClient()}. + *

+ * The Reports API enables you to request reports of activity for your Vonage account. + * It supports two modes: + *

+ *
    + *
  • Synchronous ({@link #getRecords(RecordsFilter)}): Immediately returns report data. + * Best for smaller, ad-hoc queries.
  • + *
  • Asynchronous ({@link #createReport(AsyncReportRequest)}): Submits a report job + * for background processing. Use {@link #getReport(String)} to poll the status and + * {@link #downloadReport(String)} to download the result once complete.
  • + *
+ * + * @since 9.9.0 + */ +public class ReportsClient { + + final RestEndpoint getRecords; + final RestEndpoint createReport; + final RestEndpoint getReport; + final RestEndpoint cancelReport; + final RestEndpoint downloadReport; + + /** + * Create a new ReportsClient. + * + * @param wrapper Http Wrapper used to create requests. + */ + public ReportsClient(HttpWrapper wrapper) { + @SuppressWarnings("unchecked") + final class Endpoint extends DynamicEndpoint { + Endpoint(Function pathSuffix, HttpMethod method, R... type) { + super(DynamicEndpoint.builder(type) + .responseExceptionType(ReportsResponseException.class) + .wrapper(wrapper) + .requestMethod(method) + .authMethod(ApiKeyHeaderAuthMethod.class) + .pathGetter((de, req) -> + wrapper.getHttpConfig().getApiBaseUri() + pathSuffix.apply(req) + ) + ); + } + } + + getRecords = new Endpoint<>(req -> "/v2/reports/records", HttpMethod.GET); + createReport = new Endpoint<>(req -> "/v2/reports", HttpMethod.POST); + getReport = new Endpoint<>(reportId -> "/v2/reports/" + reportId, HttpMethod.GET); + cancelReport = new Endpoint<>(reportId -> "/v2/reports/" + reportId, HttpMethod.DELETE); + downloadReport = new Endpoint<>(fileId -> "/v3/media/" + fileId, HttpMethod.GET); + } + + private static String requireId(String id, String name) { + Objects.requireNonNull(id, name + " is required."); + if (id.trim().isEmpty()) throw new IllegalArgumentException(name + " cannot be empty."); + return id; + } + + /** + * Synchronously retrieve report records for a given product and date range. + *

+ * This endpoint immediately returns data and supports pagination via cursor. For large + * datasets, consider using the asynchronous endpoint ({@link #createReport(AsyncReportRequest)}). + *

+ * + * @param filter Query parameters specifying the product, account, date range and optional filters. + * + * @return A {@link RecordsResponse} containing the records and pagination metadata. + * + * @throws ReportsResponseException If the request was unsuccessful. Possible reasons: + *
    + *
  • 401: Authentication failure.
  • + *
  • 403: Forbidden — insufficient permissions.
  • + *
  • 422: Unprocessable entity — invalid parameters.
  • + *
  • 429: Too many requests — rate limit exceeded.
  • + *
  • 500: Internal server error.
  • + *
+ */ + public RecordsResponse getRecords(RecordsFilter filter) { + return getRecords.execute(Objects.requireNonNull(filter, "Records filter is required.")); + } + + /** + * Create an asynchronous report generation request. + *

+ * The report will be processed in the background. Use the returned {@code request_id} to poll + * for status with {@link #getReport(String)}, and once the status is {@link ReportStatus#SUCCESS}, + * use the download URL from {@link ReportResponse#getDownloadUrl()} with + * {@link #downloadReport(String)} to retrieve the data. + *

+ * + * @param request The report request specifying the product, account, date range and optional filters. + * + * @return A {@link ReportResponse} containing the {@code request_id} and initial status. + * + * @throws ReportsResponseException If the request was unsuccessful. Possible reasons: + *
    + *
  • 400: Bad request — invalid parameters.
  • + *
  • 401: Authentication failure.
  • + *
  • 403: Forbidden — insufficient permissions.
  • + *
  • 422: Unprocessable entity — invalid parameters.
  • + *
  • 429: Too many requests — rate limit exceeded.
  • + *
  • 500: Internal server error.
  • + *
+ */ + public ReportResponse createReport(AsyncReportRequest request) { + return createReport.execute(Objects.requireNonNull(request, "Report request is required.")); + } + + /** + * Retrieve the current status and details of an asynchronous report. + *

+ * Reports are retained for 4 days; reports older than 4 days cannot be retrieved. + *

+ * + * @param reportId The {@code request_id} of the report to retrieve. + * + * @return A {@link ReportResponse} with the current status and report details. + * + * @throws ReportsResponseException If the request was unsuccessful. Possible reasons: + *
    + *
  • 401: Authentication failure.
  • + *
  • 404: Report not found or older than 4 days.
  • + *
  • 429: Too many requests — rate limit exceeded.
  • + *
  • 500: Internal server error.
  • + *
+ */ + public ReportResponse getReport(String reportId) { + return getReport.execute(requireId(reportId, "Report ID")); + } + + /** + * Cancel the execution of a pending or processing asynchronous report. + *

+ * Reports that have already completed ({@link ReportStatus#SUCCESS}) cannot be cancelled. + *

+ * + * @param reportId The {@code request_id} of the report to cancel. + * + * @return A {@link ReportResponse} confirming the cancellation. + * + * @throws ReportsResponseException If the request was unsuccessful. Possible reasons: + *
    + *
  • 401: Authentication failure.
  • + *
  • 404: Report not found.
  • + *
  • 409: Conflict — report cannot be cancelled in its current state.
  • + *
  • 429: Too many requests — rate limit exceeded.
  • + *
  • 500: Internal server error.
  • + *
+ */ + public ReportResponse cancelReport(String reportId) { + return cancelReport.execute(requireId(reportId, "Report ID")); + } + + /** + * Download the completed report as a zip archive containing a CSV file. + *

+ * The file is available for download for 72 hours after the report completes. + * The file ID is obtained from {@link ReportResponse#getDownloadUrl()}. + *

+ * + * @param fileId The UUID of the file to download (from the report's {@code download_report} link). + * + * @return The raw bytes of the zip archive. + * + * @throws ReportsResponseException If the request was unsuccessful. Possible reasons: + *
    + *
  • 401: Authentication failure.
  • + *
  • 404: File not found or download link expired.
  • + *
  • 429: Too many requests — rate limit exceeded.
  • + *
  • 500: Internal server error.
  • + *
+ */ + public byte[] downloadReport(String fileId) { + return downloadReport.execute(requireId(fileId, "File ID")); + } +} diff --git a/src/main/java/com/vonage/client/reports/ReportsResponseException.java b/src/main/java/com/vonage/client/reports/ReportsResponseException.java new file mode 100644 index 000000000..12f3d181d --- /dev/null +++ b/src/main/java/com/vonage/client/reports/ReportsResponseException.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.reports; + +import com.vonage.client.VonageApiResponseException; + +/** + * Exception thrown when the Reports API returns an error response. + */ +public class ReportsResponseException extends VonageApiResponseException { + + ReportsResponseException() {} + + /** + * Construct a new ReportsResponseException. + * + * @param message The error message. + */ + public ReportsResponseException(String message) { + super(message); + } + + /** + * Creates an instance of this class from a JSON payload. + * + * @param json The JSON string to parse. + * + * @return A new instance of this class. + */ + public static ReportsResponseException fromJson(String json) { + return fromJson(ReportsResponseException.class, json); + } +} diff --git a/src/main/java/com/vonage/client/reports/package-info.java b/src/main/java/com/vonage/client/reports/package-info.java new file mode 100644 index 000000000..19562083b --- /dev/null +++ b/src/main/java/com/vonage/client/reports/package-info.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Classes and interfaces for working with the + * Vonage Reports API. + * + *

The Reports API enables you to request reports of activity for your Vonage account. + * It supports both synchronous (immediate) and asynchronous (background) report generation.

+ * + *

Synchronous Reports

+ *

Use {@link com.vonage.client.reports.ReportsClient#getRecords(RecordsFilter)} for immediate + * retrieval of records. Build a {@link com.vonage.client.reports.RecordsFilter} to specify + * the product, account ID, date range and optional filters.

+ * + *

Asynchronous Reports

+ *

Use {@link com.vonage.client.reports.ReportsClient#createReport(AsyncReportRequest)} to + * submit a background report job. Poll with + * {@link com.vonage.client.reports.ReportsClient#getReport(String)} until the status is + * {@link com.vonage.client.reports.ReportStatus#SUCCESS}, then download the zip archive + * with {@link com.vonage.client.reports.ReportsClient#downloadReport(String)}.

+ */ +package com.vonage.client.reports; diff --git a/src/test/java/com/vonage/client/reports/ReportsClientTest.java b/src/test/java/com/vonage/client/reports/ReportsClientTest.java new file mode 100644 index 000000000..dfbb79c91 --- /dev/null +++ b/src/test/java/com/vonage/client/reports/ReportsClientTest.java @@ -0,0 +1,557 @@ +/* + * Copyright 2025 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.reports; + +import com.vonage.client.AbstractClientTest; +import com.vonage.client.TestUtils; +import static com.vonage.client.TestUtils.testJsonableBaseObject; +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import java.net.URI; +import java.util.List; +import java.util.Map; + +public class ReportsClientTest extends AbstractClientTest { + + static final String REPORT_ID = "aaaaaaaa-bbbb-cccc-dddd-0123456789ab"; + static final String FILE_ID = "bbbbbbbb-cccc-dddd-eeee-0123456789ab"; + static final String ACCOUNT_ID = "12aa3456"; + + static final String REPORT_RESPONSE_JSON = "{\n" + + " \"request_id\": \"" + REPORT_ID + "\",\n" + + " \"request_status\": \"PENDING\",\n" + + " \"receive_time\": \"2024-02-07T14:22:08+00:00\",\n" + + " \"start_time\": \"2024-02-07T14:22:10+00:00\",\n" + + " \"items_count\": 1523,\n" + + " \"product\": \"SMS\",\n" + + " \"account_id\": \"" + ACCOUNT_ID + "\",\n" + + " \"date_start\": \"2024-02-02T13:50:00+00:00\",\n" + + " \"date_end\": \"2024-02-07T14:22:08+00:00\",\n" + + " \"direction\": \"outbound\",\n" + + " \"_links\": {\n" + + " \"self\": {\"href\": \"https://api.nexmo.com/v2/reports/" + REPORT_ID + "\"},\n" + + " \"download_report\": {\"href\": \"https://api.nexmo.com/v3/media/" + FILE_ID + "\"}\n" + + " }\n" + + "}"; + + static final String SUCCESS_REPORT_RESPONSE_JSON = "{\n" + + " \"request_id\": \"" + REPORT_ID + "\",\n" + + " \"request_status\": \"SUCCESS\",\n" + + " \"items_count\": 50,\n" + + " \"product\": \"VOICE-CALL\",\n" + + " \"account_id\": \"" + ACCOUNT_ID + "\",\n" + + " \"_links\": {\n" + + " \"download_report\": {\"href\": \"https://api.nexmo.com/v3/media/" + FILE_ID + "\"}\n" + + " }\n" + + "}"; + + static final String RECORDS_RESPONSE_JSON = "{\n" + + " \"request_id\": \"a91b34c2-5d98-4c0e-8f23-a6b1c7d4e9f0\",\n" + + " \"request_status\": \"SUCCESS\",\n" + + " \"received_at\": \"2024-02-07T14:22:08+00:00\",\n" + + " \"items_count\": 2,\n" + + " \"product\": \"SMS\",\n" + + " \"cursor\": \"MTY0OTQ3ODAwMDAwMA\",\n" + + " \"iv\": \"8a2c4e6f-12d3-45b6-78c9-0a1b2c3d4e5f\",\n" + + " \"_links\": {\n" + + " \"self\": {\"href\": \"https://api.nexmo.com/v2/reports/sms/records\"},\n" + + " \"next\": {\"href\": \"https://api.nexmo.com/v2/reports/sms/records?cursor=MTY0OTQ3ODAwMDAwMA\"}\n" + + " },\n" + + " \"records\": [\n" + + " {\"message_id\": \"msg-1\", \"from\": \"447700900001\", \"to\": \"447700900000\", \"status\": \"delivered\"},\n" + + " {\"message_id\": \"msg-2\", \"from\": \"447700900001\", \"to\": \"447700900002\", \"status\": \"failed\"}\n" + + " ]\n" + + "}"; + + public ReportsClientTest() { + client = new ReportsClient(wrapper); + } + + // ========== Product enum tests ========== + + @Test + public void testProductEnumValues() { + assertEquals("SMS", Product.SMS.getValue()); + assertEquals("SMS-TRAFFIC-CONTROL", Product.SMS_TRAFFIC_CONTROL.getValue()); + assertEquals("VOICE-CALL", Product.VOICE_CALL.getValue()); + assertEquals("VOICE-FAILED", Product.VOICE_FAILED.getValue()); + assertEquals("VOICE-TTS", Product.VOICE_TTS.getValue()); + assertEquals("IN-APP-VOICE", Product.IN_APP_VOICE.getValue()); + assertEquals("WEBSOCKET-CALL", Product.WEBSOCKET_CALL.getValue()); + assertEquals("ASR", Product.ASR.getValue()); + assertEquals("AMD", Product.AMD.getValue()); + assertEquals("VERIFY-API", Product.VERIFY_API.getValue()); + assertEquals("VERIFY-V2", Product.VERIFY_V2.getValue()); + assertEquals("NUMBER-INSIGHT", Product.NUMBER_INSIGHT.getValue()); + assertEquals("CONVERSATION-EVENT", Product.CONVERSATION_EVENT.getValue()); + assertEquals("CONVERSATION-MESSAGE", Product.CONVERSATION_MESSAGE.getValue()); + assertEquals("MESSAGES", Product.MESSAGES.getValue()); + assertEquals("VIDEO-API", Product.VIDEO_API.getValue()); + assertEquals("NETWORK-API-EVENT", Product.NETWORK_API_EVENT.getValue()); + assertEquals("REPORTS-USAGE", Product.REPORTS_USAGE.getValue()); + assertEquals(18, Product.values().length); + } + + @Test + public void testProductFromValue() { + assertEquals(Product.SMS, Product.fromValue("SMS")); + assertEquals(Product.VOICE_CALL, Product.fromValue("VOICE-CALL")); + assertEquals(Product.MESSAGES, Product.fromValue("messages")); + assertNull(Product.fromValue("UNKNOWN")); + assertNull(Product.fromValue(null)); + } + + // ========== ReportStatus enum tests ========== + + @Test + public void testReportStatusFromValue() { + assertEquals(ReportStatus.PENDING, ReportStatus.fromValue("PENDING")); + assertEquals(ReportStatus.PROCESSING, ReportStatus.fromValue("processing")); + assertEquals(ReportStatus.SUCCESS, ReportStatus.fromValue("SUCCESS")); + assertEquals(ReportStatus.ABORTED, ReportStatus.fromValue("ABORTED")); + assertEquals(ReportStatus.FAILED, ReportStatus.fromValue("FAILED")); + assertEquals(ReportStatus.TRUNCATED, ReportStatus.fromValue("TRUNCATED")); + assertNull(ReportStatus.fromValue("UNKNOWN")); + assertNull(ReportStatus.fromValue(null)); + } + + // ========== AsyncReportRequest tests ========== + + @Test + public void testAsyncReportRequestRequiredFields() { + var request = AsyncReportRequest.builder(Product.SMS, ACCOUNT_ID).build(); + assertEquals(Product.SMS, request.getProduct()); + assertEquals(ACCOUNT_ID, request.getAccountId()); + String json = request.toJson(); + assertTrue(json.contains("\"product\":\"SMS\"")); + assertTrue(json.contains("\"account_id\":\"" + ACCOUNT_ID + "\"")); + // Optional fields should not be serialised + assertFalse(json.contains("date_start")); + assertFalse(json.contains("direction")); + } + + @Test + public void testAsyncReportRequestNullProduct() { + assertThrows(NullPointerException.class, () -> + AsyncReportRequest.builder(null, ACCOUNT_ID).build() + ); + } + + @Test + public void testAsyncReportRequestNullAccountId() { + assertThrows(NullPointerException.class, () -> + AsyncReportRequest.builder(Product.SMS, null).build() + ); + } + + @Test + public void testAsyncReportRequestEmptyAccountId() { + assertThrows(IllegalArgumentException.class, () -> + AsyncReportRequest.builder(Product.SMS, " ").build() + ); + } + + @Test + public void testAsyncReportRequestAllFields() { + var callbackUrl = URI.create("https://example.com/webhook"); + var request = AsyncReportRequest.builder(Product.MESSAGES, ACCOUNT_ID) + .dateStart("2024-02-02T13:50:00+00:00") + .dateEnd("2024-02-07T14:22:08+00:00") + .includeSubaccounts(true) + .callbackUrl(callbackUrl) + .direction("outbound") + .status("delivered") + .from("447700900001") + .to("447700900000") + .country("GB") + .network("23415") + .clientRef("my-ref") + .accountRef("acc-ref") + .includeMessage(true) + .showConcatenated(false) + .callId("call-123") + .conversationId("CON-abc123") + .legId("leg-uuid") + .provider("whatsapp") + .channel("v2") + .parentRequestId("parent-uuid") + .locale("en-gb") + .number("447700900000") + .productName("camara-sim-swap") + .requestType("check") + .requestSessionId("sess-uuid") + .productPath("/camara/sim-swap/v040/check") + .correlationId("corr-uuid") + .sessionId("sess-123") + .meetingId("meet-123") + .build(); + + assertEquals(Product.MESSAGES, request.getProduct()); + assertEquals(ACCOUNT_ID, request.getAccountId()); + assertEquals("2024-02-02T13:50:00+00:00", request.getDateStart()); + assertEquals("2024-02-07T14:22:08+00:00", request.getDateEnd()); + assertTrue(request.getIncludeSubaccounts()); + assertEquals(callbackUrl, request.getCallbackUrl()); + assertEquals("outbound", request.getDirection()); + assertEquals("delivered", request.getStatus()); + assertEquals("447700900001", request.getFrom()); + assertEquals("447700900000", request.getTo()); + assertEquals("GB", request.getCountry()); + assertEquals("23415", request.getNetwork()); + assertEquals("my-ref", request.getClientRef()); + assertEquals("acc-ref", request.getAccountRef()); + assertTrue(request.getIncludeMessage()); + assertFalse(request.getShowConcatenated()); + assertEquals("call-123", request.getCallId()); + assertEquals("CON-abc123", request.getConversationId()); + assertEquals("leg-uuid", request.getLegId()); + assertEquals("whatsapp", request.getProvider()); + assertEquals("v2", request.getChannel()); + assertEquals("parent-uuid", request.getParentRequestId()); + assertEquals("en-gb", request.getLocale()); + assertEquals("447700900000", request.getNumber()); + assertEquals("camara-sim-swap", request.getProductName()); + assertEquals("check", request.getRequestType()); + assertEquals("sess-uuid", request.getRequestSessionId()); + assertEquals("/camara/sim-swap/v040/check", request.getProductPath()); + assertEquals("corr-uuid", request.getCorrelationId()); + assertEquals("sess-123", request.getSessionId()); + assertEquals("meet-123", request.getMeetingId()); + + testJsonableBaseObject(request); + String json = request.toJson(); + assertTrue(json.contains("\"product\":\"MESSAGES\"")); + assertTrue(json.contains("\"include_subaccounts\":true")); + assertTrue(json.contains("\"callback_url\":\"https://example.com/webhook\"")); + } + + // ========== RecordsFilter tests ========== + + @Test + public void testRecordsFilterRequiredFields() { + var filter = RecordsFilter.builder(Product.SMS, ACCOUNT_ID).build(); + assertEquals(Product.SMS, filter.getProduct()); + assertEquals(ACCOUNT_ID, filter.getAccountId()); + var params = filter.makeParams(); + assertEquals("SMS", params.get("product")); + assertEquals(ACCOUNT_ID, params.get("account_id")); + assertFalse(params.containsKey("date_start")); + assertFalse(params.containsKey("direction")); + } + + @Test + public void testRecordsFilterNullProduct() { + assertThrows(NullPointerException.class, () -> + RecordsFilter.builder(null, ACCOUNT_ID).build() + ); + } + + @Test + public void testRecordsFilterNullAccountId() { + assertThrows(NullPointerException.class, () -> + RecordsFilter.builder(Product.SMS, null).build() + ); + } + + @Test + public void testRecordsFilterEmptyAccountId() { + assertThrows(IllegalArgumentException.class, () -> + RecordsFilter.builder(Product.SMS, " ").build() + ); + } + + @Test + public void testRecordsFilterAllFields() { + var filter = RecordsFilter.builder(Product.VOICE_CALL, ACCOUNT_ID) + .dateStart("2024-02-02T13:50:00+00:00") + .dateEnd("2024-02-07T14:22:08+00:00") + .cursor("MTY0OTQ3ODAwMDAwMA") + .iv("8a2c4e6f-12d3-45b6-78c9-0a1b2c3d4e5f") + .id(REPORT_ID) + .direction("outbound") + .status("ANSWERED") + .from("12345678912") + .to("22345678912") + .country("GB") + .network("23415") + .clientRef("my-ref") + .accountRef("acc-ref") + .includeMessage(true) + .showConcatenated(false) + .callId("dfc0c915f38ae6701d7d114cde2556b1-1") + .conversationId("CON-abc123") + .legId("leg-uuid") + .provider("whatsapp") + .channel("email") + .parentRequestId("parent-uuid") + .locale("en-gb") + .number("447700900000") + .numberType("mobile") + .risk("low") + .swapped(true) + .productName("camara-sim-swap") + .requestType("check") + .requestSessionId("sess-uuid") + .productPath("/camara/sim-swap") + .correlationId("corr-uuid") + .sessionId("sess-123") + .meetingId("meet-123") + .build(); + + var params = filter.makeParams(); + assertEquals("VOICE-CALL", params.get("product")); + assertEquals(ACCOUNT_ID, params.get("account_id")); + assertEquals("2024-02-02T13:50:00+00:00", params.get("date_start")); + assertEquals("MTY0OTQ3ODAwMDAwMA", params.get("cursor")); + assertEquals("8a2c4e6f-12d3-45b6-78c9-0a1b2c3d4e5f", params.get("iv")); + assertEquals(REPORT_ID, params.get("id")); + assertEquals("outbound", params.get("direction")); + assertEquals("ANSWERED", params.get("status")); + assertEquals("12345678912", params.get("from")); + assertEquals("22345678912", params.get("to")); + assertEquals("GB", params.get("country")); + assertEquals("23415", params.get("network")); + assertEquals("my-ref", params.get("client_ref")); + assertEquals("acc-ref", params.get("account_ref")); + assertEquals("true", params.get("include_message")); + assertEquals("false", params.get("show_concatenated")); + assertEquals("dfc0c915f38ae6701d7d114cde2556b1-1", params.get("call_id")); + assertEquals("CON-abc123", params.get("conversation_id")); + assertEquals("leg-uuid", params.get("leg_id")); + assertEquals("whatsapp", params.get("provider")); + assertEquals("email", params.get("channel")); + assertEquals("parent-uuid", params.get("parent_request_id")); + assertEquals("en-gb", params.get("locale")); + assertEquals("447700900000", params.get("number")); + assertEquals("mobile", params.get("number_type")); + assertEquals("low", params.get("risk")); + assertEquals("true", params.get("swapped")); + assertEquals("camara-sim-swap", params.get("product_name")); + assertEquals("check", params.get("request_type")); + assertEquals("sess-uuid", params.get("request_session_id")); + assertEquals("/camara/sim-swap", params.get("product_path")); + assertEquals("corr-uuid", params.get("correlation_id")); + assertEquals("sess-123", params.get("session_id")); + assertEquals("meet-123", params.get("meeting_id")); + + assertEquals("MTY0OTQ3ODAwMDAwMA", filter.getCursor()); + assertEquals("8a2c4e6f-12d3-45b6-78c9-0a1b2c3d4e5f", filter.getIv()); + assertEquals(REPORT_ID, filter.getId()); + assertEquals("mobile", filter.getNumberType()); + assertEquals("low", filter.getRisk()); + assertTrue(filter.getSwapped()); + } + + // ========== ReportResponse deserialization tests ========== + + @Test + public void testReportResponseDeserialization() { + var response = com.vonage.client.Jsonable.fromJson(REPORT_RESPONSE_JSON, ReportResponse.class); + testJsonableBaseObject(response); + assertEquals(REPORT_ID, response.getRequestId()); + assertEquals(ReportStatus.PENDING, response.getRequestStatus()); + assertEquals("2024-02-07T14:22:08+00:00", response.getReceiveTime()); + assertEquals("2024-02-07T14:22:10+00:00", response.getStartTime()); + assertEquals(1523L, response.getItemsCount()); + assertEquals(Product.SMS, response.getProduct()); + assertEquals(ACCOUNT_ID, response.getAccountId()); + assertEquals("2024-02-02T13:50:00+00:00", response.getDateStart()); + assertEquals("outbound", response.getDirection()); + assertNotNull(response.getLinks()); + assertEquals("https://api.nexmo.com/v3/media/" + FILE_ID, response.getDownloadUrl()); + } + + @Test + public void testReportResponseDownloadUrlNullWhenNoLinks() { + var response = new ReportResponse(); + assertNull(response.getDownloadUrl()); + } + + @Test + public void testReportResponseSuccessStatus() { + var response = com.vonage.client.Jsonable.fromJson(SUCCESS_REPORT_RESPONSE_JSON, ReportResponse.class); + assertEquals(ReportStatus.SUCCESS, response.getRequestStatus()); + assertEquals(Product.VOICE_CALL, response.getProduct()); + assertEquals(50L, response.getItemsCount()); + assertEquals("https://api.nexmo.com/v3/media/" + FILE_ID, response.getDownloadUrl()); + } + + // ========== RecordsResponse deserialization tests ========== + + @Test + public void testRecordsResponseDeserialization() { + var response = com.vonage.client.Jsonable.fromJson(RECORDS_RESPONSE_JSON, RecordsResponse.class); + testJsonableBaseObject(response); + assertEquals("a91b34c2-5d98-4c0e-8f23-a6b1c7d4e9f0", response.getRequestId()); + assertEquals(ReportStatus.SUCCESS, response.getRequestStatus()); + assertEquals("2024-02-07T14:22:08+00:00", response.getReceivedAt()); + assertEquals(2L, response.getItemsCount()); + assertEquals(Product.SMS, response.getProduct()); + assertEquals("MTY0OTQ3ODAwMDAwMA", response.getCursor()); + assertEquals("8a2c4e6f-12d3-45b6-78c9-0a1b2c3d4e5f", response.getIv()); + + List> records = response.getRecords(); + assertNotNull(records); + assertEquals(2, records.size()); + assertEquals("msg-1", records.get(0).get("message_id")); + assertEquals("delivered", records.get(0).get("status")); + assertEquals("msg-2", records.get(1).get("message_id")); + assertEquals("failed", records.get(1).get("status")); + + assertNotNull(response.getLinks()); + assertNotNull(response.getLinks().get("next")); + } + + // ========== ReportsClient method tests ========== + + @Test + public void testGetRecords() throws Exception { + stubResponse(200, RECORDS_RESPONSE_JSON); + var filter = RecordsFilter.builder(Product.SMS, ACCOUNT_ID) + .dateStart("2024-02-02T13:50:00+00:00") + .build(); + var response = client.getRecords(filter); + assertNotNull(response); + assertEquals(ReportStatus.SUCCESS, response.getRequestStatus()); + assertEquals(2L, response.getItemsCount()); + } + + @Test + public void testGetRecordsNullFilter() { + assertThrows(NullPointerException.class, () -> client.getRecords(null)); + } + + @Test + public void testGetRecords401() throws Exception { + assertApiResponseException(401, "{\"title\":\"Unauthorized\"}", ReportsResponseException.class, + () -> client.getRecords(RecordsFilter.builder(Product.SMS, ACCOUNT_ID).build()) + ); + } + + @Test + public void testCreateReport() throws Exception { + stubResponse(200, REPORT_RESPONSE_JSON); + var request = AsyncReportRequest.builder(Product.SMS, ACCOUNT_ID) + .direction("outbound") + .dateStart("2024-02-02T13:50:00+00:00") + .dateEnd("2024-02-07T14:22:08+00:00") + .build(); + var response = client.createReport(request); + assertNotNull(response); + assertEquals(REPORT_ID, response.getRequestId()); + assertEquals(ReportStatus.PENDING, response.getRequestStatus()); + } + + @Test + public void testCreateReportNullRequest() { + assertThrows(NullPointerException.class, () -> client.createReport(null)); + } + + @Test + public void testCreateReport401() throws Exception { + assertApiResponseException(401, "{\"title\":\"Unauthorized\"}", ReportsResponseException.class, + () -> client.createReport(AsyncReportRequest.builder(Product.SMS, ACCOUNT_ID).build()) + ); + } + + @Test + public void testGetReport() throws Exception { + stubResponse(200, SUCCESS_REPORT_RESPONSE_JSON); + var response = client.getReport(REPORT_ID); + assertNotNull(response); + assertEquals(REPORT_ID, response.getRequestId()); + assertEquals(ReportStatus.SUCCESS, response.getRequestStatus()); + } + + @Test + public void testGetReportNullId() { + assertThrows(NullPointerException.class, () -> client.getReport(null)); + } + + @Test + public void testGetReportEmptyId() { + assertThrows(IllegalArgumentException.class, () -> client.getReport(" ")); + } + + @Test + public void testGetReport404() throws Exception { + assertApiResponseException(404, "{\"title\":\"Not Found\"}", ReportsResponseException.class, + () -> client.getReport(REPORT_ID) + ); + } + + @Test + public void testCancelReport() throws Exception { + stubResponse(200, REPORT_RESPONSE_JSON.replace("PENDING", "ABORTED")); + var response = client.cancelReport(REPORT_ID); + assertNotNull(response); + assertEquals(REPORT_ID, response.getRequestId()); + assertEquals(ReportStatus.ABORTED, response.getRequestStatus()); + } + + @Test + public void testCancelReportNullId() { + assertThrows(NullPointerException.class, () -> client.cancelReport(null)); + } + + @Test + public void testCancelReportEmptyId() { + assertThrows(IllegalArgumentException.class, () -> client.cancelReport("")); + } + + @Test + public void testCancelReport409() throws Exception { + assertApiResponseException(409, "{\"title\":\"Conflict\"}", ReportsResponseException.class, + () -> client.cancelReport(REPORT_ID) + ); + } + + @Test + public void testDownloadReport() throws Exception { + byte[] zipData = new byte[]{0x50, 0x4B, 0x03, 0x04}; // PK (zip magic bytes) + stubResponse(200, new String(zipData)); + var result = client.downloadReport(FILE_ID); + assertNotNull(result); + } + + @Test + public void testDownloadReportNullId() { + assertThrows(NullPointerException.class, () -> client.downloadReport(null)); + } + + @Test + public void testDownloadReportEmptyId() { + assertThrows(IllegalArgumentException.class, () -> client.downloadReport("")); + } + + @Test + public void testDownloadReport404() throws Exception { + assertApiResponseException(404, "{\"title\":\"Not Found\"}", ReportsResponseException.class, + () -> client.downloadReport(FILE_ID) + ); + } + + // ========== VonageClient integration test ========== + + @Test + public void testVonageClientGetReportsClient() { + var vonageClient = com.vonage.client.VonageClient.builder() + .apiKey("key") + .apiSecret("secret") + .build(); + assertNotNull(vonageClient.getReportsClient()); + } +} From 58f42141dee0c21a6ad2d69b97af66f9d8f1634c Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Tue, 26 May 2026 13:18:19 -0400 Subject: [PATCH 2/3] DEVX-11247: Address Copilot PR review feedback - downloadReport now accepts full download URL (from getDownloadUrl()) instead of a bare file ID, matching VoiceClient#downloadRecordingRaw pattern; validates URL is from nexmo.com/vonage.com - Update getDownloadUrl() Javadoc to clarify it returns full absolute URL - Relax RecordsFilter Javadoc to note id-based constraints are API behaviour, not SDK-enforced - Remove unused TestUtils import in ReportsClientTest - Fix testDownloadReport to use ASCII payload with assertArrayEquals - Add testDownloadReportInvalidHost test case Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../vonage/client/reports/RecordsFilter.java | 6 ++-- .../vonage/client/reports/ReportResponse.java | 7 +++-- .../vonage/client/reports/ReportsClient.java | 28 +++++++++++++++---- .../client/reports/ReportsClientTest.java | 24 ++++++++++------ 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/vonage/client/reports/RecordsFilter.java b/src/main/java/com/vonage/client/reports/RecordsFilter.java index 77180bbaf..dd2e18c37 100644 --- a/src/main/java/com/vonage/client/reports/RecordsFilter.java +++ b/src/main/java/com/vonage/client/reports/RecordsFilter.java @@ -24,9 +24,9 @@ *

* Build using {@link #builder(Product, String)}, providing the required {@code product} and * {@code account_id}. Use either date-based queries ({@link Builder#dateStart}/{@link Builder#dateEnd}) - * or ID-based queries ({@link Builder#id}) — when using ID queries, only {@code product}, - * {@code account_id}, {@code direction}, {@code id}, {@code includeMessage} and - * {@code showConcatenated} are allowed. + * or ID-based queries ({@link Builder#id}). Note: when using ID-based queries, the API only processes + * {@code product}, {@code account_id}, {@code direction}, {@code id}, {@code includeMessage} and + * {@code showConcatenated} — other parameters are accepted but will be ignored by the API. *

*/ public class RecordsFilter extends AbstractQueryParamsRequest { diff --git a/src/main/java/com/vonage/client/reports/ReportResponse.java b/src/main/java/com/vonage/client/reports/ReportResponse.java index bafa43d73..103ed2f73 100644 --- a/src/main/java/com/vonage/client/reports/ReportResponse.java +++ b/src/main/java/com/vonage/client/reports/ReportResponse.java @@ -196,10 +196,11 @@ public Map> getLinks() { } /** - * Convenience method to get the download URL for the report. - * Returns the {@code href} value from the {@code download_report} HAL link. + * Convenience method to get the full download URL for the report. + * Returns the {@code href} value from the {@code download_report} HAL link, which is a + * complete absolute URL that can be passed directly to {@link ReportsClient#downloadReport(String)}. * - * @return The download URL string, or {@code null} if not available. + * @return The absolute download URL string, or {@code null} if not available. */ public String getDownloadUrl() { if (links == null) return null; diff --git a/src/main/java/com/vonage/client/reports/ReportsClient.java b/src/main/java/com/vonage/client/reports/ReportsClient.java index 9173d2633..5a1856c45 100644 --- a/src/main/java/com/vonage/client/reports/ReportsClient.java +++ b/src/main/java/com/vonage/client/reports/ReportsClient.java @@ -21,6 +21,7 @@ import com.vonage.client.VonageClient; import com.vonage.client.auth.ApiKeyHeaderAuthMethod; import com.vonage.client.common.HttpMethod; +import java.net.URI; import java.util.Objects; import java.util.function.Function; @@ -75,7 +76,13 @@ final class Endpoint extends DynamicEndpoint { createReport = new Endpoint<>(req -> "/v2/reports", HttpMethod.POST); getReport = new Endpoint<>(reportId -> "/v2/reports/" + reportId, HttpMethod.GET); cancelReport = new Endpoint<>(reportId -> "/v2/reports/" + reportId, HttpMethod.DELETE); - downloadReport = new Endpoint<>(fileId -> "/v3/media/" + fileId, HttpMethod.GET); + downloadReport = DynamicEndpoint.builder(byte[].class) + .responseExceptionType(ReportsResponseException.class) + .wrapper(wrapper) + .requestMethod(HttpMethod.GET) + .authMethod(ApiKeyHeaderAuthMethod.class) + .pathGetter((de, url) -> url) + .build(); } private static String requireId(String id, String name) { @@ -113,7 +120,7 @@ public RecordsResponse getRecords(RecordsFilter filter) { *

* The report will be processed in the background. Use the returned {@code request_id} to poll * for status with {@link #getReport(String)}, and once the status is {@link ReportStatus#SUCCESS}, - * use the download URL from {@link ReportResponse#getDownloadUrl()} with + * use the full download URL from {@link ReportResponse#getDownloadUrl()} with * {@link #downloadReport(String)} to retrieve the data. *

* @@ -184,13 +191,15 @@ public ReportResponse cancelReport(String reportId) { * Download the completed report as a zip archive containing a CSV file. *

* The file is available for download for 72 hours after the report completes. - * The file ID is obtained from {@link ReportResponse#getDownloadUrl()}. + * Pass the full URL returned by {@link ReportResponse#getDownloadUrl()}. *

* - * @param fileId The UUID of the file to download (from the report's {@code download_report} link). + * @param downloadUrl The full download URL from the report's {@code download_report} HAL link + * (obtained via {@link ReportResponse#getDownloadUrl()}). * * @return The raw bytes of the zip archive. * + * @throws IllegalArgumentException If the download URL is null, empty, or not a Vonage URL. * @throws ReportsResponseException If the request was unsuccessful. Possible reasons: *
    *
  • 401: Authentication failure.
  • @@ -199,7 +208,14 @@ public ReportResponse cancelReport(String reportId) { *
  • 500: Internal server error.
  • *
*/ - public byte[] downloadReport(String fileId) { - return downloadReport.execute(requireId(fileId, "File ID")); + public byte[] downloadReport(String downloadUrl) { + if (downloadUrl == null || downloadUrl.trim().isEmpty()) { + throw new IllegalArgumentException("Download URL is required."); + } + String validated = URI.create(downloadUrl).toString(); + if (!validated.contains(".nexmo.com/") && !validated.contains(".vonage.com/")) { + throw new IllegalArgumentException("Download URL must be a Vonage URL."); + } + return downloadReport.execute(validated); } } diff --git a/src/test/java/com/vonage/client/reports/ReportsClientTest.java b/src/test/java/com/vonage/client/reports/ReportsClientTest.java index dfbb79c91..082275448 100644 --- a/src/test/java/com/vonage/client/reports/ReportsClientTest.java +++ b/src/test/java/com/vonage/client/reports/ReportsClientTest.java @@ -16,7 +16,6 @@ package com.vonage.client.reports; import com.vonage.client.AbstractClientTest; -import com.vonage.client.TestUtils; import static com.vonage.client.TestUtils.testJsonableBaseObject; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; @@ -28,6 +27,7 @@ public class ReportsClientTest extends AbstractClientTest { static final String REPORT_ID = "aaaaaaaa-bbbb-cccc-dddd-0123456789ab"; static final String FILE_ID = "bbbbbbbb-cccc-dddd-eeee-0123456789ab"; + static final String DOWNLOAD_URL = "https://api.nexmo.com/v3/media/" + FILE_ID; static final String ACCOUNT_ID = "12aa3456"; static final String REPORT_RESPONSE_JSON = "{\n" + @@ -521,26 +521,32 @@ public void testCancelReport409() throws Exception { @Test public void testDownloadReport() throws Exception { - byte[] zipData = new byte[]{0x50, 0x4B, 0x03, 0x04}; // PK (zip magic bytes) - stubResponse(200, new String(zipData)); - var result = client.downloadReport(FILE_ID); - assertNotNull(result); + byte[] expectedBytes = "".getBytes(); + stubResponse(200, new String(expectedBytes)); + assertArrayEquals(expectedBytes, client.downloadReport(DOWNLOAD_URL)); } @Test - public void testDownloadReportNullId() { - assertThrows(NullPointerException.class, () -> client.downloadReport(null)); + public void testDownloadReportNullUrl() { + assertThrows(IllegalArgumentException.class, () -> client.downloadReport(null)); } @Test - public void testDownloadReportEmptyId() { + public void testDownloadReportEmptyUrl() { assertThrows(IllegalArgumentException.class, () -> client.downloadReport("")); } + @Test + public void testDownloadReportInvalidHost() { + assertThrows(IllegalArgumentException.class, () -> + client.downloadReport("https://evil.com/v3/media/" + FILE_ID) + ); + } + @Test public void testDownloadReport404() throws Exception { assertApiResponseException(404, "{\"title\":\"Not Found\"}", ReportsResponseException.class, - () -> client.downloadReport(FILE_ID) + () -> client.downloadReport(DOWNLOAD_URL) ); } From c83692e297d358093faf372b0e55126f0cb2c638 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Tue, 26 May 2026 15:44:39 -0400 Subject: [PATCH 3/3] DEVX-11247: downloadReport accepts file ID only (security hardening) Revert to constructing the download URL internally from the file ID to prevent open-redirect/SSRF risks. Add ReportResponse#getFileId() as a convenience helper that extracts the UUID from the download_report HAL link, so callers can easily chain getFileId() into downloadReport(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../vonage/client/reports/ReportResponse.java | 17 +++++++++-- .../vonage/client/reports/ReportsClient.java | 28 ++++--------------- .../client/reports/ReportsClientTest.java | 19 +++++-------- 3 files changed, 28 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/vonage/client/reports/ReportResponse.java b/src/main/java/com/vonage/client/reports/ReportResponse.java index 103ed2f73..7f502cf07 100644 --- a/src/main/java/com/vonage/client/reports/ReportResponse.java +++ b/src/main/java/com/vonage/client/reports/ReportResponse.java @@ -197,8 +197,7 @@ public Map> getLinks() { /** * Convenience method to get the full download URL for the report. - * Returns the {@code href} value from the {@code download_report} HAL link, which is a - * complete absolute URL that can be passed directly to {@link ReportsClient#downloadReport(String)}. + * Returns the {@code href} value from the {@code download_report} HAL link. * * @return The absolute download URL string, or {@code null} if not available. */ @@ -209,6 +208,20 @@ public String getDownloadUrl() { return downloadReport.get("href"); } + /** + * Convenience method to extract the file ID from the report's download link. + * This is the last path segment of the download URL, and should be passed to + * {@link ReportsClient#downloadReport(String)}. + * + * @return The file UUID string, or {@code null} if not available. + */ + public String getFileId() { + String url = getDownloadUrl(); + if (url == null) return null; + int lastSlash = url.lastIndexOf('/'); + return lastSlash >= 0 ? url.substring(lastSlash + 1) : null; + } + /** * The product type for this report. * diff --git a/src/main/java/com/vonage/client/reports/ReportsClient.java b/src/main/java/com/vonage/client/reports/ReportsClient.java index 5a1856c45..00ef4a29f 100644 --- a/src/main/java/com/vonage/client/reports/ReportsClient.java +++ b/src/main/java/com/vonage/client/reports/ReportsClient.java @@ -21,7 +21,6 @@ import com.vonage.client.VonageClient; import com.vonage.client.auth.ApiKeyHeaderAuthMethod; import com.vonage.client.common.HttpMethod; -import java.net.URI; import java.util.Objects; import java.util.function.Function; @@ -76,13 +75,7 @@ final class Endpoint extends DynamicEndpoint { createReport = new Endpoint<>(req -> "/v2/reports", HttpMethod.POST); getReport = new Endpoint<>(reportId -> "/v2/reports/" + reportId, HttpMethod.GET); cancelReport = new Endpoint<>(reportId -> "/v2/reports/" + reportId, HttpMethod.DELETE); - downloadReport = DynamicEndpoint.builder(byte[].class) - .responseExceptionType(ReportsResponseException.class) - .wrapper(wrapper) - .requestMethod(HttpMethod.GET) - .authMethod(ApiKeyHeaderAuthMethod.class) - .pathGetter((de, url) -> url) - .build(); + downloadReport = new Endpoint<>(fileId -> "/v3/media/" + fileId, HttpMethod.GET); } private static String requireId(String id, String name) { @@ -120,7 +113,7 @@ public RecordsResponse getRecords(RecordsFilter filter) { *

* The report will be processed in the background. Use the returned {@code request_id} to poll * for status with {@link #getReport(String)}, and once the status is {@link ReportStatus#SUCCESS}, - * use the full download URL from {@link ReportResponse#getDownloadUrl()} with + * use the file ID from {@link ReportResponse#getFileId()} with * {@link #downloadReport(String)} to retrieve the data. *

* @@ -191,15 +184,13 @@ public ReportResponse cancelReport(String reportId) { * Download the completed report as a zip archive containing a CSV file. *

* The file is available for download for 72 hours after the report completes. - * Pass the full URL returned by {@link ReportResponse#getDownloadUrl()}. + * The file ID can be obtained via {@link ReportResponse#getFileId()}. *

* - * @param downloadUrl The full download URL from the report's {@code download_report} HAL link - * (obtained via {@link ReportResponse#getDownloadUrl()}). + * @param fileId The UUID of the file to download, obtained from {@link ReportResponse#getFileId()}. * * @return The raw bytes of the zip archive. * - * @throws IllegalArgumentException If the download URL is null, empty, or not a Vonage URL. * @throws ReportsResponseException If the request was unsuccessful. Possible reasons: *
    *
  • 401: Authentication failure.
  • @@ -208,14 +199,7 @@ public ReportResponse cancelReport(String reportId) { *
  • 500: Internal server error.
  • *
*/ - public byte[] downloadReport(String downloadUrl) { - if (downloadUrl == null || downloadUrl.trim().isEmpty()) { - throw new IllegalArgumentException("Download URL is required."); - } - String validated = URI.create(downloadUrl).toString(); - if (!validated.contains(".nexmo.com/") && !validated.contains(".vonage.com/")) { - throw new IllegalArgumentException("Download URL must be a Vonage URL."); - } - return downloadReport.execute(validated); + public byte[] downloadReport(String fileId) { + return downloadReport.execute(requireId(fileId, "File ID")); } } diff --git a/src/test/java/com/vonage/client/reports/ReportsClientTest.java b/src/test/java/com/vonage/client/reports/ReportsClientTest.java index 082275448..3833433f2 100644 --- a/src/test/java/com/vonage/client/reports/ReportsClientTest.java +++ b/src/test/java/com/vonage/client/reports/ReportsClientTest.java @@ -372,12 +372,14 @@ public void testReportResponseDeserialization() { assertEquals("outbound", response.getDirection()); assertNotNull(response.getLinks()); assertEquals("https://api.nexmo.com/v3/media/" + FILE_ID, response.getDownloadUrl()); + assertEquals(FILE_ID, response.getFileId()); } @Test public void testReportResponseDownloadUrlNullWhenNoLinks() { var response = new ReportResponse(); assertNull(response.getDownloadUrl()); + assertNull(response.getFileId()); } @Test @@ -523,30 +525,23 @@ public void testCancelReport409() throws Exception { public void testDownloadReport() throws Exception { byte[] expectedBytes = "".getBytes(); stubResponse(200, new String(expectedBytes)); - assertArrayEquals(expectedBytes, client.downloadReport(DOWNLOAD_URL)); + assertArrayEquals(expectedBytes, client.downloadReport(FILE_ID)); } @Test - public void testDownloadReportNullUrl() { - assertThrows(IllegalArgumentException.class, () -> client.downloadReport(null)); + public void testDownloadReportNullId() { + assertThrows(NullPointerException.class, () -> client.downloadReport(null)); } @Test - public void testDownloadReportEmptyUrl() { + public void testDownloadReportEmptyId() { assertThrows(IllegalArgumentException.class, () -> client.downloadReport("")); } - @Test - public void testDownloadReportInvalidHost() { - assertThrows(IllegalArgumentException.class, () -> - client.downloadReport("https://evil.com/v3/media/" + FILE_ID) - ); - } - @Test public void testDownloadReport404() throws Exception { assertApiResponseException(404, "{\"title\":\"Not Found\"}", ReportsResponseException.class, - () -> client.downloadReport(DOWNLOAD_URL) + () -> client.downloadReport(FILE_ID) ); }