Skip to content

Commit 0c2ba17

Browse files
shai-almogclaude
andauthored
Backend: streaming request and response bodies, spooled uploads, HttpProxy (#6095)
* Backend: streaming bodies, spooled uploads, Web.Exchange and HttpProxy Large binary uploads spool to an unlinked file on HTTP/1.1 and HTTP/2 alike; Web.open streams outbound requests both ways; HttpProxy forwards with hop-by-hop stripping, a destination allow-list and cancellation both ways. HTTP/2 used to serve an upload truncated at the body limit: frames in flight after the refusing chunk still completed the request. Such a stream is now answered 413, as HTTP/1.1 answers it. Fixes #5994 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: contractions for Vale, mark server snippets cn1-no-try Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ea7ec99 commit 0c2ba17

19 files changed

Lines changed: 4129 additions & 151 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
3+
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
* This code is free software; you can redistribute it and/or modify it
5+
* under the terms of the GNU General Public License version 2 only, as
6+
* published by the Free Software Foundation. Codename One designates this
7+
* particular file as subject to the "Classpath" exception as provided
8+
* by Oracle in the LICENSE file that accompanied this code.
9+
*
10+
* This code is distributed in the hope that it will be useful, but WITHOUT
11+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13+
* version 2 for more details (a copy is included in the LICENSE file that
14+
* accompanied this code).
15+
*
16+
* You should have received a copy of the GNU General Public License version
17+
* 2 along with this work; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
19+
*
20+
* Please contact Codename One through http://www.codenameone.com/ if you
21+
* need additional information or have any questions.
22+
*/
23+
package com.codenameone.developerguide.backend;
24+
25+
import com.codename1.backend.HttpProxy;
26+
import com.codename1.backend.HttpServer;
27+
import com.codename1.backend.Web;
28+
import com.codename1.backend.annotations.GetMapping;
29+
import com.codename1.backend.annotations.PostMapping;
30+
import com.codename1.backend.annotations.RequestMapping;
31+
import com.codename1.backend.annotations.RestController;
32+
import java.io.IOException;
33+
import java.io.InputStream;
34+
import java.io.OutputStream;
35+
import java.util.Collections;
36+
import java.util.Map;
37+
38+
/** Streaming request and response bodies, and a reverse proxy. */
39+
@RestController
40+
public class Streaming {
41+
private final HttpProxy images;
42+
43+
public Streaming() throws IOException {
44+
// tag::backend-web-proxy-create[]
45+
images = new HttpProxy("https://images.internal:8443")
46+
.allowHost("cdn.internal");
47+
// end::backend-web-proxy-create[]
48+
}
49+
50+
// tag::backend-web-upload-stream[]
51+
@PostMapping("/archive")
52+
public Map<String, Long> archive(HttpServer.Request request) throws IOException {
53+
long total = 0;
54+
byte[] buffer = new byte[64 * 1024];
55+
InputStream in = request.getBodyStream();
56+
try {
57+
for (int n = in.read(buffer); n > 0; n = in.read(buffer)) {
58+
total += n; // store, hash or forward each block
59+
}
60+
} finally {
61+
in.close();
62+
}
63+
return Collections.singletonMap("bytes", total);
64+
}
65+
// end::backend-web-upload-stream[]
66+
67+
// tag::backend-web-download-stream[]
68+
@GetMapping("/report.csv")
69+
public HttpServer.Response report() {
70+
return HttpServer.Response.stream(200, "text/csv", new HttpServer.BodyWriter() {
71+
public void writeTo(OutputStream out) throws IOException {
72+
for (int row = 0; row < 1000000; row++) {
73+
out.write((row + "," + row * row + "\n").getBytes("UTF-8"));
74+
}
75+
}
76+
});
77+
}
78+
// end::backend-web-download-stream[]
79+
80+
// tag::backend-web-exchange[]
81+
static long upload(InputStream file, long length) throws IOException {
82+
Web.Exchange exchange = Web.open("PUT", "https://storage.internal/blobs/1",
83+
Collections.singletonList("Content-Type: application/octet-stream"), length);
84+
try {
85+
OutputStream up = exchange.getRequestBody();
86+
byte[] buffer = new byte[64 * 1024];
87+
for (int n = file.read(buffer); n > 0; n = file.read(buffer)) {
88+
up.write(buffer, 0, n);
89+
}
90+
up.close();
91+
if (exchange.getStatus() >= 300) {
92+
throw new IOException("storage answered " + exchange.getStatus());
93+
}
94+
InputStream down = exchange.getResponseBody();
95+
long answered = 0;
96+
while (down.read() >= 0) {
97+
answered++;
98+
}
99+
return answered;
100+
} finally {
101+
exchange.close(); // before the end, this cancels it
102+
}
103+
}
104+
// end::backend-web-exchange[]
105+
106+
// tag::backend-web-proxy-forward[]
107+
@RequestMapping("/images/{name}")
108+
public HttpServer.Response image(HttpServer.Request request) {
109+
return images.forward(request); // the same path and query, upstream
110+
}
111+
// end::backend-web-proxy-forward[]
112+
}

‎docs/developer-guide/Backend-Web.asciidoc‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,48 @@ for any other body. A body that isn't text -- an upload, an image,
254254
form, after checking that it's valid UTF-8, and refuses a binary body rather
255255
than hand the handler garbled characters.
256256

257+
==== Large uploads and downloads
258+
259+
A binary body can be larger than 8 MB when `CN1_HTTP_MAX_SPOOLED_BODY_MB` raises
260+
its limit. Such a body is never held in memory whole. Up to
261+
`CN1_HTTP_SPOOL_THRESHOLD_KB` (1024 by default) it stays on the heap, and past
262+
that the server writes it to a temporary file as it arrives. The file is deleted
263+
from the directory as soon as it's created, so a crash leaves nothing behind.
264+
`CN1_HTTP_SPOOL_DIR` chooses the directory, the system temporary directory
265+
otherwise. A body over the limit is answered 413, over HTTP/1.1 and HTTP/2
266+
alike. JSON, forms, XML and compressed bodies keep the 8 MB limit, because a
267+
handler reads those whole anyway.
268+
269+
Read such a body with `getBodyStream()`, which reads the file or the memory
270+
without the handler knowing which. `getBodyLength()` gives its size without
271+
reading it. A multipart upload is spooled the same way, and each
272+
`HttpServer.Part` has `getInputStream()` and `getLength()` for it:
273+
274+
[source,java,role=cn1-no-try]
275+
----
276+
include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-upload-stream,indent=0]
277+
----
278+
279+
The body is valid until the request ends. A handler that needs it later copies
280+
it somewhere of its own.
281+
282+
A response is streamed with `HttpServer.Response.stream`. The writer runs after
283+
the headers are sent and writes as much as it likes: chunked on HTTP/1.1, DATA
284+
frames on HTTP/2. A write blocks while the client is slow, so a slow client
285+
slows the writer rather than filling memory, and a client that goes away makes
286+
the next write throw.
287+
288+
[source,java,role=cn1-no-try]
289+
----
290+
include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-download-stream,indent=0]
291+
----
292+
293+
The server doesn't run the writer for a `HEAD` request, a status without a
294+
body, or a client that left before the headers went out. A writer that holds
295+
something open until it runs, such as a connection or a file, should also
296+
implement `java.io.Closeable`. In those cases the server calls `close()`
297+
instead.
298+
257299
==== Compression
258300

259301
`cn1.server.compression.enabled=true` turns on gzip for responses, with the two
@@ -765,6 +807,63 @@ HTTP/2 fails every request with an exception that says so. It reads PKCS#12
765807
archives, and PEM keys in unencrypted PKCS#8 or RSA form. The packaged server also
766808
reads encrypted PEM keys. A PKCS#12 archive works the same in both.
767809

810+
==== Streaming a request
811+
812+
`Web.request` holds the whole response in memory. `Web.open` (or
813+
`WebClient.open`) returns a `Web.Exchange` instead, which sends the request body
814+
as it's written and reads the response as it arrives. The length is the body's
815+
size, `Web.UNKNOWN_LENGTH` for a body of unknown size, or `Web.NO_BODY`:
816+
817+
[source,java,role=cn1-no-try]
818+
----
819+
include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-exchange,indent=0]
820+
----
821+
822+
Write and close the request body first, then read the status, the headers and
823+
the response body, all from one thread. Each direction buffers a fixed window,
824+
so a slow peer slows its producer. `close()` ends the exchange in any state.
825+
Before the response has been read to its end it cancels the exchange, closing
826+
the connection instead of draining it, so always close it in a `finally`. An
827+
exchange never follows a redirect, because a streamed body can't be sent twice.
828+
829+
==== Forwarding to another service
830+
831+
`HttpProxy` forwards a request to another server and streams the answer back,
832+
the way a gateway in front of an internal service would:
833+
834+
[source,java,role=cn1-no-try]
835+
----
836+
include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-proxy-create,indent=0]
837+
----
838+
839+
[source,java,role=cn1-no-try]
840+
----
841+
include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Streaming.java[tag=backend-web-proxy-forward,indent=0]
842+
----
843+
844+
The response body is streamed back as it arrives, and a binary upload is
845+
streamed up from wherever it lives, a spooled one included. A text or compressed
846+
request body is read whole on arrival, under the 8 MB limit, and forwarded
847+
decompressed. Every end-to-end header goes
848+
through, a repeated one such as `Set-Cookie` line by line. The hop-by-hop
849+
headers (`Connection`, `Keep-Alive`, `Proxy-*`, `TE`, `Trailer`,
850+
`Transfer-Encoding`, `Upgrade`) and any header a `Connection` field names are
851+
removed in both directions. `X-Forwarded-For`, `X-Forwarded-Proto` and
852+
`X-Forwarded-Host` are added unless `setForwardedHeaders(false)` turns them off.
853+
854+
Only the target's host is reached, plus the hosts named with `allowHost`, over
855+
`http` and `https`. Redirects are passed back to the client.
856+
`setFollowRedirects(n)` follows up to `n` hops itself, for a request without a
857+
body and only to allowed hosts. A hop to another host doesn't carry the
858+
client's `Authorization` and `Cookie` headers.
859+
860+
Failures go both ways. A client that disconnects cancels the upstream exchange.
861+
An upstream that fails before answering is a `502`. Once the status has been
862+
sent, a later upstream failure aborts the client's response, closing the
863+
HTTP/1.1 connection or resetting the HTTP/2 stream, so the client sees a
864+
truncated response and never one that looks complete. `setClient` gives the
865+
proxy a `WebClient` for its timeouts and client certificate.
866+
768867
A static binary in a `scratch` image has no certificate store. Copy a
769868
`ca-certificates.crt` into the image and name it in `CURL_CA_BUNDLE` or
770869
`SSL_CERT_FILE`.

0 commit comments

Comments
 (0)