Skip to content

Commit 5e9e2e0

Browse files
committed
feat: add streaming download helpers
1 parent 4b1afc0 commit 5e9e2e0

6 files changed

Lines changed: 217 additions & 1 deletion

File tree

src/main/java/io/facturapi/http/FacturapiHttpClient.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.fasterxml.jackson.databind.ObjectMapper;
66
import io.facturapi.FacturapiException;
77
import java.io.IOException;
8+
import java.io.InputStream;
89
import java.net.URI;
910
import java.net.URLEncoder;
1011
import java.net.http.HttpClient;
@@ -82,6 +83,14 @@ public byte[] postBytes(String path, Object body) {
8283
return requestBytes("POST", path, body);
8384
}
8485

86+
public InputStream getStream(String path) {
87+
return requestStream("GET", path, null);
88+
}
89+
90+
public InputStream postStream(String path, Object body) {
91+
return requestStream("POST", path, body);
92+
}
93+
8594
private JsonNode requestJsonNode(
8695
String method,
8796
String path,
@@ -126,6 +135,42 @@ private byte[] requestBytes(String method, String path, Object body) {
126135
}
127136
}
128137

138+
private InputStream requestStream(String method, String path, Object body) {
139+
try {
140+
HttpRequest request = buildRequest(method, path, null, body, null);
141+
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
142+
int statusCode = response.statusCode();
143+
if (statusCode < 200 || statusCode >= 300) {
144+
byte[] responseBytes;
145+
try (InputStream errorStream = response.body()) {
146+
responseBytes = errorStream == null ? new byte[0] : errorStream.readAllBytes();
147+
}
148+
String bodyText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
149+
String message = "Request failed with status " + statusCode;
150+
try {
151+
JsonNode error = objectMapper.readTree(bodyText);
152+
JsonNode messageNode = firstDefined(error, "message", "error", "detail");
153+
if (messageNode != null && messageNode.isTextual()) {
154+
message = messageNode.asText();
155+
} else if (!bodyText.isEmpty()) {
156+
message = bodyText;
157+
}
158+
} catch (Exception ignored) {
159+
if (!bodyText.isEmpty()) {
160+
message = bodyText;
161+
}
162+
}
163+
throw new FacturapiException(message, statusCode, bodyText);
164+
}
165+
return response.body();
166+
} catch (IOException e) {
167+
throw new FacturapiException("I/O error when calling Facturapi API", e);
168+
} catch (InterruptedException e) {
169+
Thread.currentThread().interrupt();
170+
throw new FacturapiException("Request interrupted", e);
171+
}
172+
}
173+
129174
private <T> T convert(JsonNode node, Class<T> type) {
130175
if (type == Void.class) {
131176
return null;

src/main/java/io/facturapi/resources/InvoicesResource.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import io.facturapi.models.GenericResponse;
66
import io.facturapi.models.Invoice;
77
import io.facturapi.models.SearchResult;
8+
import java.io.InputStream;
89
import java.util.Map;
910

1011
public class InvoicesResource extends BaseResource {
@@ -86,6 +87,17 @@ public byte[] downloadPdf(String id) {
8687
return client.getBytes("/invoices/" + id + "/pdf");
8788
}
8889

90+
/**
91+
* Opens a streaming invoice PDF download.
92+
*
93+
* @param id Invoice id.
94+
* @return PDF stream. Caller owns closing it.
95+
* @see <a href="https://docs.facturapi.io/api#operation/downloadInvoice">API reference</a>
96+
*/
97+
public InputStream downloadPdfStream(String id) {
98+
return client.getStream("/invoices/" + id + "/pdf");
99+
}
100+
89101
/**
90102
* Downloads an invoice XML file.
91103
*
@@ -97,6 +109,17 @@ public byte[] downloadXml(String id) {
97109
return client.getBytes("/invoices/" + id + "/xml");
98110
}
99111

112+
/**
113+
* Opens a streaming invoice XML download.
114+
*
115+
* @param id Invoice id.
116+
* @return XML stream. Caller owns closing it.
117+
* @see <a href="https://docs.facturapi.io/api#operation/downloadInvoice">API reference</a>
118+
*/
119+
public InputStream downloadXmlStream(String id) {
120+
return client.getStream("/invoices/" + id + "/xml");
121+
}
122+
100123
/**
101124
* Downloads an invoice ZIP package with PDF and XML.
102125
*
@@ -108,6 +131,17 @@ public byte[] downloadZip(String id) {
108131
return client.getBytes("/invoices/" + id + "/zip");
109132
}
110133

134+
/**
135+
* Opens a streaming invoice ZIP download.
136+
*
137+
* @param id Invoice id.
138+
* @return ZIP stream. Caller owns closing it.
139+
* @see <a href="https://docs.facturapi.io/api#operation/downloadInvoice">API reference</a>
140+
*/
141+
public InputStream downloadZipStream(String id) {
142+
return client.getStream("/invoices/" + id + "/zip");
143+
}
144+
111145
/**
112146
* Downloads the cancellation receipt XML file.
113147
*
@@ -119,6 +153,17 @@ public byte[] downloadCancellationReceiptXml(String id) {
119153
return client.getBytes("/invoices/" + id + "/cancellation_receipt/xml");
120154
}
121155

156+
/**
157+
* Opens a streaming cancellation receipt XML download.
158+
*
159+
* @param id Invoice id.
160+
* @return XML stream. Caller owns closing it.
161+
* @see <a href="https://docs.facturapi.io/api#operation/downloadCancellationReceiptXml">API reference</a>
162+
*/
163+
public InputStream downloadCancellationReceiptXmlStream(String id) {
164+
return client.getStream("/invoices/" + id + "/cancellation_receipt/xml");
165+
}
166+
122167
/**
123168
* Downloads the cancellation receipt PDF file.
124169
*
@@ -130,6 +175,17 @@ public byte[] downloadCancellationReceiptPdf(String id) {
130175
return client.getBytes("/invoices/" + id + "/cancellation_receipt/pdf");
131176
}
132177

178+
/**
179+
* Opens a streaming cancellation receipt PDF download.
180+
*
181+
* @param id Invoice id.
182+
* @return PDF stream. Caller owns closing it.
183+
* @see <a href="https://docs.facturapi.io/api#operation/downloadCancellationReceiptXml">API reference</a>
184+
*/
185+
public InputStream downloadCancellationReceiptPdfStream(String id) {
186+
return client.getStream("/invoices/" + id + "/cancellation_receipt/pdf");
187+
}
188+
133189
/**
134190
* Updates a draft invoice.
135191
*

src/main/java/io/facturapi/resources/ReceiptsResource.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.facturapi.models.Invoice;
77
import io.facturapi.models.Receipt;
88
import io.facturapi.models.SearchResult;
9+
import java.io.InputStream;
910
import java.util.Map;
1011

1112
public class ReceiptsResource extends BaseResource {
@@ -107,4 +108,15 @@ public GenericResponse sendByEmail(String id, Map<String, Object> data) {
107108
public byte[] downloadPdf(String id) {
108109
return client.getBytes("/receipts/" + id + "/pdf");
109110
}
111+
112+
/**
113+
* Opens a streaming receipt PDF download.
114+
*
115+
* @param id Receipt id.
116+
* @return PDF stream. Caller owns closing it.
117+
* @see <a href="https://docs.facturapi.io/api#operation/downloadReceiptPdf">API reference</a>
118+
*/
119+
public InputStream downloadPdfStream(String id) {
120+
return client.getStream("/receipts/" + id + "/pdf");
121+
}
110122
}

src/main/java/io/facturapi/resources/RetentionsResource.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import io.facturapi.models.GenericResponse;
66
import io.facturapi.models.Retention;
77
import io.facturapi.models.SearchResult;
8+
import java.io.InputStream;
89
import java.util.Map;
910

1011
public class RetentionsResource extends BaseResource {
@@ -85,6 +86,17 @@ public byte[] downloadPdf(String id) {
8586
return client.getBytes("/retentions/" + id + "/pdf");
8687
}
8788

89+
/**
90+
* Opens a streaming retention PDF download.
91+
*
92+
* @param id Retention id.
93+
* @return PDF stream. Caller owns closing it.
94+
* @see <a href="https://docs.facturapi.io/api#operation/downloadRetention">API reference</a>
95+
*/
96+
public InputStream downloadPdfStream(String id) {
97+
return client.getStream("/retentions/" + id + "/pdf");
98+
}
99+
88100
/**
89101
* Downloads a retention XML file.
90102
*
@@ -96,6 +108,17 @@ public byte[] downloadXml(String id) {
96108
return client.getBytes("/retentions/" + id + "/xml");
97109
}
98110

111+
/**
112+
* Opens a streaming retention XML download.
113+
*
114+
* @param id Retention id.
115+
* @return XML stream. Caller owns closing it.
116+
* @see <a href="https://docs.facturapi.io/api#operation/downloadRetention">API reference</a>
117+
*/
118+
public InputStream downloadXmlStream(String id) {
119+
return client.getStream("/retentions/" + id + "/xml");
120+
}
121+
99122
/**
100123
* Downloads a retention ZIP package with PDF and XML.
101124
*
@@ -106,4 +129,15 @@ public byte[] downloadXml(String id) {
106129
public byte[] downloadZip(String id) {
107130
return client.getBytes("/retentions/" + id + "/zip");
108131
}
132+
133+
/**
134+
* Opens a streaming retention ZIP download.
135+
*
136+
* @param id Retention id.
137+
* @return ZIP stream. Caller owns closing it.
138+
* @see <a href="https://docs.facturapi.io/api#operation/downloadRetention">API reference</a>
139+
*/
140+
public InputStream downloadZipStream(String id) {
141+
return client.getStream("/retentions/" + id + "/zip");
142+
}
109143
}

src/test/java/io/facturapi/FacturapiResourcesTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import io.facturapi.enums.Taxability;
1616
import io.facturapi.http.FacturapiConfig;
1717
import io.facturapi.models.Customer;
18+
import java.io.InputStream;
19+
import java.nio.charset.StandardCharsets;
1820
import java.time.Instant;
1921
import java.time.LocalDate;
2022
import java.util.Map;
@@ -41,6 +43,25 @@ void invoiceCreateUsesExpectedPath() {
4143
assertEquals("/v2/invoices?test=true", request.uri().getPath() + "?" + request.uri().getQuery());
4244
}
4345

46+
@Test
47+
void invoicePdfCanBeStreamed() throws Exception {
48+
StubHttpClient httpClient = new StubHttpClient();
49+
httpClient.enqueueBinary(200, "PDF-CONTENT".getBytes(), "application/pdf");
50+
51+
Facturapi sdk = new Facturapi(
52+
FacturapiConfig.builder("sk_test")
53+
.httpClient(httpClient)
54+
.build()
55+
);
56+
57+
try (InputStream stream = sdk.invoices().downloadPdfStream("inv_1")) {
58+
assertEquals("PDF-CONTENT", new String(stream.readAllBytes(), StandardCharsets.UTF_8));
59+
}
60+
61+
var request = httpClient.requests().get(0);
62+
assertEquals("/v2/invoices/inv_1/pdf", request.uri().getPath());
63+
}
64+
4465
@Test
4566
void sdkExposesAccessorBasedSurface() {
4667
Facturapi sdk = new Facturapi(

src/test/java/io/facturapi/StubHttpClient.java

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.facturapi;
22

33
import java.io.IOException;
4+
import java.io.InputStream;
45
import java.net.Authenticator;
56
import java.net.CookieHandler;
67
import java.net.ProxySelector;
@@ -10,13 +11,15 @@
1011
import java.net.http.HttpRequest;
1112
import java.net.http.HttpResponse;
1213
import java.time.Duration;
14+
import java.nio.ByteBuffer;
1315
import java.util.ArrayDeque;
1416
import java.util.ArrayList;
1517
import java.util.Deque;
1618
import java.util.List;
1719
import java.util.Map;
1820
import java.util.Optional;
1921
import java.util.concurrent.CompletableFuture;
22+
import java.util.concurrent.Flow;
2023
import java.util.concurrent.Executor;
2124
import javax.net.ssl.SSLContext;
2225
import javax.net.ssl.SSLParameters;
@@ -108,7 +111,7 @@ public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T>
108111
throw new IOException("No queued response");
109112
}
110113
@SuppressWarnings("unchecked")
111-
T body = (T) queued.body;
114+
T body = (T) buildBody(responseBodyHandler, queued.body, queued.statusCode, queued.headers);
112115
return new StubHttpResponse<>(request, queued.statusCode, body, queued.headers);
113116
}
114117

@@ -188,4 +191,49 @@ public Version version() {
188191
return Version.HTTP_1_1;
189192
}
190193
}
194+
195+
private static <T> T buildBody(
196+
HttpResponse.BodyHandler<T> responseBodyHandler,
197+
byte[] body,
198+
int statusCode,
199+
Map<String, List<String>> headers
200+
) {
201+
HttpResponse.ResponseInfo responseInfo = new ResponseInfo(statusCode, headers);
202+
HttpResponse.BodySubscriber<T> subscriber = responseBodyHandler.apply(responseInfo);
203+
subscriber.onSubscribe(new Flow.Subscription() {
204+
@Override
205+
public void request(long n) {}
206+
207+
@Override
208+
public void cancel() {}
209+
});
210+
subscriber.onNext(List.of(ByteBuffer.wrap(body)));
211+
subscriber.onComplete();
212+
return subscriber.getBody().toCompletableFuture().join();
213+
}
214+
215+
private static final class ResponseInfo implements HttpResponse.ResponseInfo {
216+
private final int statusCode;
217+
private final HttpHeaders headers;
218+
219+
ResponseInfo(int statusCode, Map<String, List<String>> headers) {
220+
this.statusCode = statusCode;
221+
this.headers = HttpHeaders.of(headers, (k, v) -> true);
222+
}
223+
224+
@Override
225+
public int statusCode() {
226+
return statusCode;
227+
}
228+
229+
@Override
230+
public HttpHeaders headers() {
231+
return headers;
232+
}
233+
234+
@Override
235+
public HttpClient.Version version() {
236+
return HttpClient.Version.HTTP_1_1;
237+
}
238+
}
191239
}

0 commit comments

Comments
 (0)