JLHTTP in JPMS
ci/woodpecker/push/woodpecker Pipeline was successful Details

This commit is contained in:
Johannes Frohnmeyer 2023-05-05 17:48:31 +02:00
parent c35369bfde
commit 28b08998b1
Signed by: Johannes
GPG Key ID: E76429612C2929F4
17 changed files with 3180 additions and 0 deletions

17
commons-jlhttp/LICENSE Normal file
View File

@ -0,0 +1,17 @@
This component of JfCommons is based on JLHTTP, which is GPL-licensed:
Copyright (C) 2021 Curt Cox
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

View File

@ -0,0 +1,19 @@
import io.gitlab.jfronny.scripts.*
plugins {
id("commons.library")
}
dependencies {
}
publishing {
publications {
create<MavenPublication>("maven") {
groupId = "io.gitlab.jfronny"
artifactId = "commons-jlhttp"
from(components["java"])
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
package io.gitlab.jfronny.commons.jlhttp.api;
import io.gitlab.jfronny.commons.jlhttp.util.VirtualHost;
import java.lang.annotation.*;
/**
* The {@code Context} annotation decorates methods which are mapped
* to a context (path) within the server, and provide its contents.
* <p>
* The annotated methods must have the same signature and contract
* as {@link ContextHandler#serve}, but can have arbitrary names.
*
* @see VirtualHost#addContexts(Object)
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Context {
/**
* The context (path) that this field maps to (must begin with '/').
*
* @return the context (path) that this field maps to
*/
String value();
/**
* The HTTP methods supported by this context handler (default is "GET").
*
* @return the HTTP methods supported by this context handler
*/
String[] methods() default "GET";
}

View File

@ -0,0 +1,28 @@
package io.gitlab.jfronny.commons.jlhttp.api;
import io.gitlab.jfronny.commons.jlhttp.JLHTTPServer;
import io.gitlab.jfronny.commons.jlhttp.util.VirtualHost;
import java.io.IOException;
/**
* A {@code ContextHandler} serves the content of resources within a context.
*
* @see VirtualHost#addContext
*/
public interface ContextHandler {
/**
* Serves the given request using the given response.
*
* @param req the request to be served
* @param resp the response to be filled
* @return an HTTP status code, which will be used in returning
* a default response appropriate for this status. If this
* method invocation already sent anything in the response
* (headers or content), it must return 0, and no further
* processing will be done
* @throws IOException if an IO error occurs
*/
int serve(JLHTTPServer.Request req, JLHTTPServer.Response resp) throws IOException;
}

View File

@ -0,0 +1,86 @@
package io.gitlab.jfronny.commons.jlhttp.io;
import io.gitlab.jfronny.commons.jlhttp.*;
import io.gitlab.jfronny.commons.jlhttp.util.Headers;
import java.io.IOException;
import java.io.InputStream;
/**
* The {@code ChunkedInputStream} decodes an InputStream whose data has the
* "chunked" transfer encoding applied to it, providing the underlying data.
*/
public class ChunkedInputStream extends LimitedInputStream {
protected Headers headers;
protected boolean initialized;
/**
* Constructs a ChunkedInputStream with the given underlying stream, and
* a headers container to which the stream's trailing headers will be
* added.
*
* @param in the underlying "chunked"-encoded input stream
* @param headers the headers container to which the stream's trailing
* headers will be added, or null if they are to be discarded
* @throws NullPointerException if the given stream is null
*/
public ChunkedInputStream(InputStream in, Headers headers) {
super(in, 0, true);
this.headers = headers;
}
@Override
public int read() throws IOException {
return limit <= 0 && initChunk() < 0 ? -1 : super.read();
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
return limit <= 0 && initChunk() < 0 ? -1 : super.read(b, off, len);
}
/**
* Initializes the next chunk. If the previous chunk has not yet
* ended, or the end of stream has been reached, does nothing.
*
* @return the length of the chunk, or -1 if the end of stream
* has been reached
* @throws IOException if an IO error occurs or the stream is corrupt
*/
protected long initChunk() throws IOException {
if (limit == 0) { // finished previous chunk
// read chunk-terminating CRLF if it's not the first chunk
if (initialized && JLHTTPServer.readLine(in).length() > 0)
throw new IOException("chunk data must end with CRLF");
initialized = true;
limit = parseChunkSize(JLHTTPServer.readLine(in)); // read next chunk size
if (limit == 0) { // last chunk has size 0
limit = -1; // mark end of stream
// read trailing headers, if any
Headers trailingHeaders = JLHTTPServer.readHeaders(in);
if (headers != null)
headers.addAll(trailingHeaders);
}
}
return limit;
}
/**
* Parses a chunk-size line.
*
* @param line the chunk-size line to parse
* @return the chunk size
* @throws IllegalArgumentException if the chunk-size line is invalid
*/
protected static long parseChunkSize(String line) throws IllegalArgumentException {
int pos = line.indexOf(';');
line = pos < 0 ? line : line.substring(0, pos); // ignore params, if any
try {
return JLHTTPServer.parseULong(line, 16); // throws NFE
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException(
"invalid chunk size line: \"" + line + "\"");
}
}
}

View File

@ -0,0 +1,112 @@
package io.gitlab.jfronny.commons.jlhttp.io;
import io.gitlab.jfronny.commons.jlhttp.util.Headers;
import io.gitlab.jfronny.commons.jlhttp.JLHTTPServer;
import java.io.*;
/**
* The {@code ChunkedOutputStream} encodes an OutputStream with the
* "chunked" transfer encoding. It should be used only when the content
* length is not known in advance, and with the response Transfer-Encoding
* header set to "chunked".
* <p>
* Data is written to the stream by calling the {@link #write(byte[], int, int)}
* method, which writes a new chunk per invocation. To end the stream,
* the {@link #writeTrailingChunk} method must be called or the stream closed.
*/
public class ChunkedOutputStream extends FilterOutputStream {
protected int state; // the current stream state
/**
* Constructs a ChunkedOutputStream with the given underlying stream.
*
* @param out the underlying output stream to which the chunked stream
* is written
* @throws NullPointerException if the given stream is null
*/
public ChunkedOutputStream(OutputStream out) {
super(out);
if (out == null)
throw new NullPointerException("output stream is null");
}
/**
* Initializes a new chunk with the given size.
*
* @param size the chunk size (must be positive)
* @throws IllegalArgumentException if size is negative
* @throws IOException if an IO error occurs, or the stream has
* already been ended
*/
protected void initChunk(long size) throws IOException {
if (size < 0)
throw new IllegalArgumentException("invalid size: " + size);
if (state > 0)
out.write(JLHTTPServer.CRLF); // end previous chunk
else if (state == 0)
state = 1; // start first chunk
else
throw new IOException("chunked stream has already ended");
out.write(JLHTTPServer.getBytes(Long.toHexString(size)));
out.write(JLHTTPServer.CRLF);
}
/**
* Writes the trailing chunk which marks the end of the stream.
*
* @param headers the (optional) trailing headers to write, or null
* @throws IOException if an error occurs
*/
public void writeTrailingChunk(Headers headers) throws IOException {
initChunk(0); // zero-sized chunk marks the end of the stream
if (headers == null)
out.write(JLHTTPServer.CRLF); // empty header block
else
headers.writeTo(out);
state = -1;
}
/**
* Writes a chunk containing the given byte. This method initializes
* a new chunk of size 1, and then writes the byte as the chunk data.
*
* @param b the byte to write as a chunk
* @throws IOException if an error occurs
*/
@Override
public void write(int b) throws IOException {
write(new byte[]{(byte) b}, 0, 1);
}
/**
* Writes a chunk containing the given bytes. This method initializes
* a new chunk of the given size, and then writes the chunk data.
*
* @param b an array containing the bytes to write
* @param off the offset within the array where the data starts
* @param len the length of the data in bytes
* @throws IOException if an error occurs
* @throws IndexOutOfBoundsException if the given offset or length
* are outside the bounds of the given array
*/
@Override
public void write(byte[] b, int off, int len) throws IOException {
if (len > 0) // zero-sized chunk is the trailing chunk
initChunk(len);
out.write(b, off, len);
}
/**
* Writes the trailing chunk if necessary, and closes the underlying stream.
*
* @throws IOException if an error occurs
*/
@Override
public void close() throws IOException {
if (state > -1)
writeTrailingChunk(null);
super.close();
}
}

View File

@ -0,0 +1,79 @@
package io.gitlab.jfronny.commons.jlhttp.io;
import java.io.*;
/**
* The {@code LimitedInputStream} provides access to a limited number
* of consecutive bytes from the underlying InputStream, starting at its
* current position. If this limit is reached, it behaves as though the end
* of stream has been reached (although the underlying stream remains open
* and may contain additional data).
*/
public class LimitedInputStream extends FilterInputStream {
protected long limit; // decremented when read, until it reaches zero
protected boolean prematureEndException;
/**
* Constructs a LimitedInputStream with the given underlying
* input stream and limit.
*
* @param in the underlying input stream
* @param limit the maximum number of bytes that may be consumed from
* the underlying stream before this stream ends. If zero or
* negative, this stream will be at its end from initialization.
* @param prematureEndException specifies the stream's behavior when
* the underlying stream end is reached before the limit is
* reached: if true, an exception is thrown, otherwise this
* stream reaches its end as well (i.e. read() returns -1)
* @throws NullPointerException if the given stream is null
*/
public LimitedInputStream(InputStream in, long limit, boolean prematureEndException) {
super(in);
if (in == null)
throw new NullPointerException("input stream is null");
this.limit = limit < 0 ? 0 : limit;
this.prematureEndException = prematureEndException;
}
@Override
public int read() throws IOException {
int res = limit == 0 ? -1 : in.read();
if (res < 0 && limit > 0 && prematureEndException)
throw new IOException("unexpected end of stream");
limit = res < 0 ? 0 : limit - 1;
return res;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
int res = limit == 0 ? -1 : in.read(b, off, len > limit ? (int) limit : len);
if (res < 0 && limit > 0 && prematureEndException)
throw new IOException("unexpected end of stream");
limit = res < 0 ? 0 : limit - res;
return res;
}
@Override
public long skip(long len) throws IOException {
long res = in.skip(len > limit ? limit : len);
limit -= res;
return res;
}
@Override
public int available() throws IOException {
int res = in.available();
return res > limit ? (int) limit : res;
}
@Override
public boolean markSupported() {
return false;
}
@Override
public void close() {
limit = 0; // end this stream, but don't close the underlying stream
}
}

View File

@ -0,0 +1,182 @@
package io.gitlab.jfronny.commons.jlhttp.io;
import io.gitlab.jfronny.commons.jlhttp.JLHTTPServer;
import java.io.*;
/**
* The {@code MultipartInputStream} decodes an InputStream whose data has
* a "multipart/*" content type (see RFC 2046), providing the underlying
* data of its various parts.
* <p>
* The {@code InputStream} methods (e.g. {@link #read}) relate only to
* the current part, and the {@link #nextPart} method advances to the
* beginning of the next part.
*/
public class MultipartInputStream extends FilterInputStream {
protected final byte[] boundary; // including leading CRLF--
protected final byte[] buf = new byte[4096];
protected int head, tail; // indices of current part's data in buf
protected int end; // last index of input data read into buf
protected int len; // length of found boundary
protected int state; // initial, started data, start boundary, EOS, last boundary, epilogue
/**
* Constructs a MultipartInputStream with the given underlying stream.
*
* @param in the underlying multipart stream
* @param boundary the multipart boundary
* @throws NullPointerException if the given stream or boundary is null
* @throws IllegalArgumentException if the given boundary's size is not
* between 1 and 70
*/
protected MultipartInputStream(InputStream in, byte[] boundary) {
super(in);
int len = boundary.length;
if (len == 0 || len > 70)
throw new IllegalArgumentException("invalid boundary length");
this.boundary = new byte[len + 4]; // CRLF--boundary
System.arraycopy(JLHTTPServer.CRLF, 0, this.boundary, 0, 2);
this.boundary[2] = this.boundary[3] = '-';
System.arraycopy(boundary, 0, this.boundary, 4, len);
}
@Override
public int read() throws IOException {
if (!fill())
return -1;
return buf[head++] & 0xFF;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
if (!fill())
return -1;
len = Math.min(tail - head, len);
System.arraycopy(buf, head, b, off, len); // throws IOOBE as necessary
head += len;
return len;
}
@Override
public long skip(long len) throws IOException {
if (len <= 0 || !fill())
return 0;
len = Math.min(tail - head, len);
head += len;
return len;
}
@Override
public int available() throws IOException {
return tail - head;
}
@Override
public boolean markSupported() {
return false;
}
/**
* Advances the stream position to the beginning of the next part.
* Data read before calling this method for the first time is the preamble,
* and data read after this method returns false is the epilogue.
*
* @return true if successful, or false if there are no more parts
* @throws IOException if an error occurs
*/
public boolean nextPart() throws IOException {
while (skip(buf.length) != 0) ; // skip current part (until boundary)
head = tail += len; // the next part starts right after boundary
state |= 1; // started data (after first boundary)
if (state >= 8) { // found last boundary
state |= 0x10; // now beyond last boundary (epilogue)
return false;
}
findBoundary(); // update indices
return true;
}
/**
* Fills the buffer with more data from the underlying stream.
*
* @return true if there is available data for the current part,
* or false if the current part's end has been reached
* @throws IOException if an error occurs or the input format is invalid
*/
protected boolean fill() throws IOException {
// check if we already have more available data
if (head != tail) // remember that if we continue, head == tail below
return true;
// if there's no more room, shift extra unread data to beginning of buffer
if (tail > buf.length - 256) { // max boundary + whitespace supported size
System.arraycopy(buf, tail, buf, 0, end -= tail);
head = tail = 0;
}
// read more data and look for boundary (or potential partial boundary)
int read;
do {
read = super.read(buf, end, buf.length - end);
if (read < 0)
state |= 4; // end of stream (EOS)
else
end += read;
findBoundary(); // updates tail and length to next potential boundary
// if we found a partial boundary with no data before it, we must
// continue reading to determine if there is more data or not
} while (read > 0 && tail == head && len == 0);
// update and validate state
if (tail != 0) // anything but a boundary right at the beginning
state |= 1; // started data (preamble or after boundary)
if (state < 8 && len > 0)
state |= 2; // found start boundary
if ((state & 6) == 4 // EOS but no start boundary found
|| len == 0 && ((state & 0xFC) == 4 // EOS but no last and no more boundaries
|| read == 0 && tail == head)) // boundary longer than buffer
throw new IOException("missing boundary");
if (state >= 0x10) // in epilogue
tail = end; // ignore boundaries, return everything
return tail > head; // available data in current part
}
/**
* Finds the first (potential) boundary within the buffer's remaining data.
* Updates tail, length and state fields accordingly.
*
* @throws IOException if an error occurs or the input format is invalid
*/
protected void findBoundary() throws IOException {
// see RFC2046#5.1.1 for boundary syntax
len = 0;
int off = tail - ((state & 1) != 0 || buf[0] != '-' ? 0 : 2); // skip initial CRLF?
for (int end = this.end; tail < end; tail++, off = tail) {
int j = tail; // end of potential boundary
// try to match boundary value (leading CRLF is optional at first boundary)
while (j < end && j - off < boundary.length && buf[j] == boundary[j - off])
j++;
// return potential partial boundary which is cut off at end of current data
if (j + 1 >= end) // at least two more chars needed for full boundary (CRLF or --)
return;
// if we found the boundary value, expand selection to include full line
if (j - off == boundary.length) {
// check if last boundary of entire multipart
if (buf[j] == '-' && buf[j + 1] == '-') {
j += 2;
state |= 8; // found last boundary that ends multipart
}
// allow linear whitespace after boundary
while (j < end && (buf[j] == ' ' || buf[j] == '\t'))
j++;
// check for CRLF (required, except in last boundary with no epilogue)
if (j + 1 < end && buf[j] == '\r' && buf[j + 1] == '\n') // found CRLF
len = j - tail + 2; // including optional whitespace and CRLF
else if (j + 1 < end || (state & 4) != 0 && j + 1 == end) // should have found or never will
throw new IOException("boundary must end with CRLF");
else if ((state & 4) != 0) // last boundary with no CRLF at end of data is valid
len = j - tail;
return;
}
}
}
}

View File

@ -0,0 +1,139 @@
package io.gitlab.jfronny.commons.jlhttp.io;
import io.gitlab.jfronny.commons.jlhttp.JLHTTPServer;
import io.gitlab.jfronny.commons.jlhttp.util.VirtualHost;
import io.gitlab.jfronny.commons.jlhttp.api.Context;
import io.gitlab.jfronny.commons.jlhttp.util.Headers;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
/**
* The {@code MultipartIterator} iterates over the parts of a multipart/form-data request.
* <p>
* For example, to support file upload from a web browser:
* <ol>
* <li>Create an HTML form which includes an input field of type "file", attributes
* method="post" and enctype="multipart/form-data", and an action URL of your choice,
* for example action="/upload". This form can be served normally like any other
* resource, e.g. from an HTML file on disk.
* <li>Add a context handler for the action path ("/upload" in this example), using either
* the explicit {@link VirtualHost#addContext} method or the {@link Context} annotation.
* <li>In the context handler implementation, construct a {@code MultipartIterator} from
* the client {@code Request}.
* <li>Iterate over the form {@link Part}s, processing each named field as appropriate -
* for the file input field, read the uploaded file using the body input stream.
* </ol>
*/
public class MultipartIterator implements Iterator<MultipartIterator.Part> {
/**
* The {@code Part} class encapsulates a single part of the multipart.
*/
public static class Part {
public String name;
public String filename;
public Headers headers;
public InputStream body;
/**
* Returns the part's name (form field name).
*
* @return the part's name
*/
public String getName() {
return name;
}
/**
* Returns the part's filename (original filename entered in file form field).
*
* @return the part's filename, or null if there is none
*/
public String getFilename() {
return filename;
}
/**
* Returns the part's headers.
*
* @return the part's headers
*/
public Headers getHeaders() {
return headers;
}
/**
* Returns the part's body (form field value).
*
* @return the part's body
*/
public InputStream getBody() {
return body;
}
/***
* Returns the part's body as a string. If the part
* headers do not specify a charset, UTF-8 is used.
*
* @return the part's body as a string
* @throws IOException if an IO error occurs
*/
public String getString() throws IOException {
String charset = headers.getParams("Content-Type").get("charset");
return JLHTTPServer.readToken(body, -1, charset == null ? "UTF-8" : charset, 8192);
}
}
protected final MultipartInputStream in;
protected boolean next;
/**
* Creates a new MultipartIterator from the given request.
*
* @param req the multipart/form-data request
* @throws IOException if an IO error occurs
* @throws IllegalArgumentException if the given request's content type
* is not multipart/form-data, or is missing the boundary
*/
public MultipartIterator(JLHTTPServer.Request req) throws IOException {
Map<String, String> ct = req.getHeaders().getParams("Content-Type");
if (!ct.containsKey("multipart/form-data"))
throw new IllegalArgumentException("Content-Type is not multipart/form-data");
String boundary = ct.get("boundary"); // should be US-ASCII
if (boundary == null)
throw new IllegalArgumentException("Content-Type is missing boundary");
in = new MultipartInputStream(req.getBody(), JLHTTPServer.getBytes(boundary));
}
public boolean hasNext() {
try {
return next || (next = in.nextPart());
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
}
public Part next() {
if (!hasNext())
throw new NoSuchElementException();
next = false;
Part p = new Part();
try {
p.headers = JLHTTPServer.readHeaders(in);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
Map<String, String> cd = p.headers.getParams("Content-Disposition");
p.name = cd.get("name");
p.filename = cd.get("filename");
p.body = in;
return p;
}
public void remove() {
throw new UnsupportedOperationException();
}
}

View File

@ -0,0 +1,24 @@
package io.gitlab.jfronny.commons.jlhttp.util;
import io.gitlab.jfronny.commons.jlhttp.JLHTTPServer;
import io.gitlab.jfronny.commons.jlhttp.api.ContextHandler;
import java.io.File;
import java.io.IOException;
/**
* The {@code FileContextHandler} services a context by mapping it
* to a file or folder (recursively) on disk.
*/
public class FileContextHandler implements ContextHandler {
protected final File base;
public FileContextHandler(File dir) throws IOException {
this.base = dir.getCanonicalFile();
}
public int serve(JLHTTPServer.Request req, JLHTTPServer.Response resp) throws IOException {
return JLHTTPServer.serveFile(base, req.getContext().getPath(), req, resp);
}
}

View File

@ -0,0 +1,45 @@
package io.gitlab.jfronny.commons.jlhttp.util;
/**
* The {@code Header} class encapsulates a single HTTP header.
*/
public class Header {
protected final String name;
protected final String value;
/**
* Constructs a header with the given name and value.
* Leading and trailing whitespace are trimmed.
*
* @param name the header name
* @param value the header value
* @throws NullPointerException if name or value is null
* @throws IllegalArgumentException if name is empty
*/
public Header(String name, String value) {
this.name = name.trim();
this.value = value.trim();
// RFC2616#14.23 - header can have an empty value (e.g. Host)
if (this.name.length() == 0) // but name cannot be empty
throw new IllegalArgumentException("name cannot be empty");
}
/**
* Returns this header's name.
*
* @return this header's name
*/
public String getName() {
return name;
}
/**
* Returns this header's value.
*
* @return this header's value
*/
public String getValue() {
return value;
}
}

View File

@ -0,0 +1,182 @@
package io.gitlab.jfronny.commons.jlhttp.util;
import io.gitlab.jfronny.commons.jlhttp.JLHTTPServer;
import java.io.IOException;
import java.io.OutputStream;
import java.util.*;
/**
* The {@code Headers} class encapsulates a collection of HTTP headers.
* <p>
* Header names are treated case-insensitively, although this class retains
* their original case. Header insertion order is maintained as well.
*/
public class Headers implements Iterable<Header> {
// due to the requirements of case-insensitive name comparisons,
// retaining the original case, and retaining header insertion order,
// and due to the fact that the number of headers is generally
// quite small (usually under 12 headers), we use a simple array with
// linear access times, which proves to be more efficient and
// straightforward than the alternatives
protected Header[] headers = new Header[12];
protected int count;
/**
* Returns the number of added headers.
*
* @return the number of added headers
*/
public int size() {
return count;
}
/**
* Returns the value of the first header with the given name.
*
* @param name the header name (case insensitive)
* @return the header value, or null if none exists
*/
public String get(String name) {
for (int i = 0; i < count; i++)
if (headers[i].getName().equalsIgnoreCase(name))
return headers[i].getValue();
return null;
}
/**
* Returns the Date value of the header with the given name.
*
* @param name the header name (case insensitive)
* @return the header value as a Date, or null if none exists
* or if the value is not in any supported date format
*/
public Date getDate(String name) {
try {
String header = get(name);
return header == null ? null : JLHTTPServer.parseDate(header);
} catch (IllegalArgumentException iae) {
return null;
}
}
/**
* Returns whether there exists a header with the given name.
*
* @param name the header name (case insensitive)
* @return whether there exists a header with the given name
*/
public boolean contains(String name) {
return get(name) != null;
}
/**
* Adds a header with the given name and value to the end of this
* collection of headers. Leading and trailing whitespace are trimmed.
*
* @param name the header name (case insensitive)
* @param value the header value
*/
public void add(String name, String value) {
Header header = new Header(name, value); // also validates
// expand array if necessary
if (count == headers.length) {
Header[] expanded = new Header[2 * count];
System.arraycopy(headers, 0, expanded, 0, count);
headers = expanded;
}
headers[count++] = header; // inlining header would cause a bug!
}
/**
* Adds all given headers to the end of this collection of headers,
* in their original order.
*
* @param headers the headers to add
*/
public void addAll(Headers headers) {
for (Header header : headers)
add(header.getName(), header.getValue());
}
/**
* Adds a header with the given name and value, replacing the first
* existing header with the same name. If there is no existing header
* with the same name, it is added as in {@link #add}.
*
* @param name the header name (case insensitive)
* @param value the header value
* @return the replaced header, or null if none existed
*/
public Header replace(String name, String value) {
for (int i = 0; i < count; i++) {
if (headers[i].getName().equalsIgnoreCase(name)) {
Header prev = headers[i];
headers[i] = new Header(name, value);
return prev;
}
}
add(name, value);
return null;
}
/**
* Removes all headers with the given name (if any exist).
*
* @param name the header name (case insensitive)
*/
public void remove(String name) {
int j = 0;
for (int i = 0; i < count; i++)
if (!headers[i].getName().equalsIgnoreCase(name))
headers[j++] = headers[i];
while (count > j)
headers[--count] = null;
}
/**
* Writes the headers to the given stream (including trailing CRLF).
*
* @param out the stream to write the headers to
* @throws IOException if an error occurs
*/
public void writeTo(OutputStream out) throws IOException {
for (int i = 0; i < count; i++) {
out.write(JLHTTPServer.getBytes(headers[i].getName(), ": ", headers[i].getValue()));
out.write(JLHTTPServer.CRLF);
}
out.write(JLHTTPServer.CRLF); // ends header block
}
/**
* Returns a header's parameters. Parameter order is maintained,
* and the first key (in iteration order) is the header's value
* without the parameters.
*
* @param name the header name (case insensitive)
* @return the header's parameter names and values
*/
public Map<String, String> getParams(String name) {
Map<String, String> params = new LinkedHashMap<String, String>();
for (String param : JLHTTPServer.split(get(name), ";", -1)) {
String[] pair = JLHTTPServer.split(param, "=", 2);
String val = pair.length == 1 ? "" : JLHTTPServer.trimLeft(JLHTTPServer.trimRight(pair[1], '"'), '"');
params.put(pair[0], val);
}
return params;
}
/**
* Returns an iterator over the headers, in their insertion order.
* If the headers collection is modified during iteration, the
* iteration result is undefined. The remove operation is unsupported.
*
* @return an Iterator over the headers
*/
public Iterator<Header> iterator() {
// we use the built-in wrapper instead of a trivial custom implementation
// since even a tiny anonymous class here compiles to a 1.5K class file
return Arrays.asList(headers).subList(0, count).iterator();
}
}

View File

@ -0,0 +1,44 @@
package io.gitlab.jfronny.commons.jlhttp.util;
import io.gitlab.jfronny.commons.jlhttp.JLHTTPServer;
import io.gitlab.jfronny.commons.jlhttp.api.ContextHandler;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
/**
* The {@code MethodContextHandler} services a context
* by invoking a handler method on a specified object.
* <p>
* The method must have the same signature and contract as
* {@link ContextHandler#serve}, but can have an arbitrary name.
*
* @see VirtualHost#addContexts(Object)
*/
public class MethodContextHandler implements ContextHandler {
protected final Method m;
protected final Object obj;
public MethodContextHandler(Method m, Object obj) throws IllegalArgumentException {
this.m = m;
this.obj = obj;
Class<?>[] params = m.getParameterTypes();
if (params.length != 2
|| !JLHTTPServer.Request.class.isAssignableFrom(params[0])
|| !JLHTTPServer.Response.class.isAssignableFrom(params[1])
|| !int.class.isAssignableFrom(m.getReturnType()))
throw new IllegalArgumentException("invalid method signature: " + m);
}
public int serve(JLHTTPServer.Request req, JLHTTPServer.Response resp) throws IOException {
try {
return (Integer) m.invoke(obj, req, resp);
} catch (InvocationTargetException ite) {
throw new IOException("error: " + ite.getCause().getMessage());
} catch (Exception e) {
throw new IOException("error: " + e);
}
}
}

View File

@ -0,0 +1,228 @@
package io.gitlab.jfronny.commons.jlhttp.util;
import io.gitlab.jfronny.commons.jlhttp.JLHTTPServer;
import io.gitlab.jfronny.commons.jlhttp.api.Context;
import io.gitlab.jfronny.commons.jlhttp.api.ContextHandler;
import java.lang.reflect.Method;
import java.util.*;
import java.util.concurrent.*;
/**
* The {@code VirtualHost} class represents a virtual host in the server.
*/
public class VirtualHost {
/**
* The {@code ContextInfo} class holds a single context's information.
*/
public class ContextInfo {
protected final String path;
protected final Map<String, ContextHandler> handlers =
new ConcurrentHashMap<>(2);
/**
* Constructs a ContextInfo with the given context path.
*
* @param path the context path (without trailing slash)
*/
public ContextInfo(String path) {
this.path = path;
}
/**
* Returns the context path.
*
* @return the context path, or null if there is none
*/
public String getPath() {
return path;
}
/**
* Returns the map of supported HTTP methods and their corresponding handlers.
*
* @return the map of supported HTTP methods and their corresponding handlers
*/
public Map<String, ContextHandler> getHandlers() {
return handlers;
}
/**
* Adds (or replaces) a context handler for the given HTTP methods.
*
* @param handler the context handler
* @param methods the HTTP methods supported by the handler (default is "GET")
*/
public void addHandler(ContextHandler handler, String... methods) {
if (methods.length == 0)
methods = new String[]{"GET"};
for (String method : methods) {
handlers.put(method, handler);
VirtualHost.this.methods.add(method); // it's now supported by server
}
}
}
protected final String name;
protected final Set<String> aliases = new CopyOnWriteArraySet<>();
protected volatile String directoryIndex = "index.html";
protected volatile boolean allowGeneratedIndex;
protected final Set<String> methods = new CopyOnWriteArraySet<>();
protected final ContextInfo emptyContext = new ContextInfo(null);
protected final ConcurrentMap<String, ContextInfo> contexts =
new ConcurrentHashMap<>();
/**
* Constructs a VirtualHost with the given name.
*
* @param name the host's name, or null if it is the default host
*/
public VirtualHost(String name) {
this.name = name;
contexts.put("*", new ContextInfo(null)); // for "OPTIONS *"
}
/**
* Returns this host's name.
*
* @return this host's name, or null if it is the default host
*/
public String getName() {
return name;
}
/**
* Adds an alias for this host.
*
* @param alias the alias
*/
public void addAlias(String alias) {
aliases.add(alias);
}
/**
* Returns this host's aliases.
*
* @return the (unmodifiable) set of aliases (which may be empty)
*/
public Set<String> getAliases() {
return Collections.unmodifiableSet(aliases);
}
/**
* Sets the directory index file. For every request whose URI ends with
* a '/' (i.e. a directory), the index file is appended to the path,
* and the resulting resource is served if it exists. If it does not
* exist, an auto-generated index for the requested directory may be
* served, depending on whether {@link #setAllowGeneratedIndex
* a generated index is allowed}, otherwise an error is returned.
* The default directory index file is "index.html".
*
* @param directoryIndex the directory index file, or null if no
* index file should be used
*/
public void setDirectoryIndex(String directoryIndex) {
this.directoryIndex = directoryIndex;
}
/**
* Gets this host's directory index file.
*
* @return the directory index file, or null
*/
public String getDirectoryIndex() {
return directoryIndex;
}
/**
* Sets whether auto-generated indices are allowed. If false, and a
* directory resource is requested, an error will be returned instead.
*
* @param allowed specifies whether generated indices are allowed
*/
public void setAllowGeneratedIndex(boolean allowed) {
this.allowGeneratedIndex = allowed;
}
/**
* Returns whether auto-generated indices are allowed.
*
* @return whether auto-generated indices are allowed
*/
public boolean isAllowGeneratedIndex() {
return allowGeneratedIndex;
}
/**
* Returns all HTTP methods explicitly supported by at least one context
* (this may or may not include the methods with required or built-in support).
*
* @return all HTTP methods explicitly supported by at least one context
*/
public Set<String> getMethods() {
return methods;
}
/**
* Returns the context handler for the given path.
* <p>
* If a context is not found for the given path, the search is repeated for
* its parent path, and so on until a base context is found. If neither the
* given path nor any of its parents has a context, an empty context is returned.
*
* @param path the context's path
* @return the context info for the given path, or an empty context if none exists
*/
public ContextInfo getContext(String path) {
// all context paths are without trailing slash
for (path = JLHTTPServer.trimRight(path, '/'); path != null; path = JLHTTPServer.getParentPath(path)) {
ContextInfo info = contexts.get(path);
if (info != null)
return info;
}
return emptyContext;
}
/**
* Adds a context and its corresponding context handler to this server.
* Paths are normalized by removing trailing slashes (except the root).
*
* @param path the context's path (must start with '/')
* @param handler the context handler for the given path
* @param methods the HTTP methods supported by the context handler (default is "GET")
* @throws IllegalArgumentException if path is malformed
*/
public void addContext(String path, ContextHandler handler, String... methods) {
if (path == null || !path.startsWith("/") && !path.equals("*"))
throw new IllegalArgumentException("invalid path: " + path);
path = JLHTTPServer.trimRight(path, '/'); // remove trailing slash
ContextInfo info = new ContextInfo(path);
ContextInfo existing = contexts.putIfAbsent(path, info);
info = existing != null ? existing : info;
info.addHandler(handler, methods);
}
/**
* Adds contexts for all methods of the given object that
* are annotated with the {@link Context} annotation.
*
* @param o the object whose annotated methods are added
* @throws IllegalArgumentException if a Context-annotated
* method has an {@link Context invalid signature}
*/
public void addContexts(Object o) throws IllegalArgumentException {
for (Class<?> c = o.getClass(); c != null; c = c.getSuperclass()) {
// add to contexts those with @Context annotation
for (Method m : c.getDeclaredMethods()) {
Context context = m.getAnnotation(Context.class);
if (context != null) {
m.setAccessible(true); // allow access to private method
ContextHandler handler = new MethodContextHandler(m, o);
addContext(context.value(), handler, context.methods());
}
}
}
}
}

View File

@ -0,0 +1,6 @@
module io.gitlab.jfronny.commons.jlhttp {
exports io.gitlab.jfronny.commons.jlhttp;
exports io.gitlab.jfronny.commons.jlhttp.api;
exports io.gitlab.jfronny.commons.jlhttp.io;
exports io.gitlab.jfronny.commons.jlhttp.util;
}

View File

@ -1,6 +1,7 @@
rootProject.name = "Commons"
include("commons-gson")
include("commons-jlhttp")
include("commons-manifold")
include("commons-slf4j")
include("muscript")