Introduction
Hello everyone! Today, I want to introduce you to my pet project - the jrest2 library, which provides a complete implementation of HTTP/HTTPS protocols from scratch. This library is already available on GitHub and published on jitpack.io with released versions. In this post, I will talk about the features of jrest2, its usage, and how you can get started with it.
What is jrest2?
This library implements a complete HTTP/HTTPS protocol from scratch. It currently supports the following versions of the HTTP protocol:
VERSION | SUPPORTED |
---|---|
HTTP/1.0 | ✅ Supported |
HTTP/1.1 | ✅ Supported |
HTTP/2 | ⛔ Not Supported |
HTTP/3 | ⛔ Not Supported |
On top of a completely self-contained protocol implementation is built a layered API structure with different configurations and ways of initializing and applying data to the connection flow.
Also, this library implements the ability to apply and read SSL certificates for both the client part of the connection (read/write) and the server part of the connection.
To use the data library in your project, you need to prescribe a dependency.
Below is an example of how to use the dependency for different build systems:
Maven
Dependency block for Maven structure project:
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>com.github.MikhailSterkhov</groupId>
<artifactId>jrest2</artifactId>
<version>${jrest.version}</version>
</dependency>
Gradle
Dependency block for Gradle structure project:
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}
compileOnly 'com.github.MikhailSterkhov:jrest2:${jrest.version}'
Features and Advantages of jrest2
- Complete implementation of HTTP/HTTPS protocols
- Binary executable client files
- Support for SSL certificates for secure connections
- Layered API structure for flexible configuration and usage
- Easy integration and use
Getting Started
- Installation: Install the library using jitpack.io. Installation instructions can be found in the GitHub repository.
- Documentation: Refer to the documentation in the README file of the repository.
- Usage Examples: Explore usage examples to understand how to get started with the library.
WIKI
Here are some examples of how to use the functionality of the top-level API to interact with the HTTP protocol
CLIENTS
Let's start with the client part of the connection.
First of all, it is necessary to determine what type of channel we will work with and what we need.
For this purpose, the client factory is implemented com.jrest.http.client.HttpClients
:
// variants of Sockets implementation:
HttpClients.createSocketClient(ExecutorService);
HttpClients.createSocketClient(ExecutorService, boolean keepAlive);
HttpClients.createSocketClient(ExecutorService, int connectTimeout);
HttpClients.createSocketClient(ExecutorService, int connectTimeout, boolean keepAlive);
HttpClients.createSocketClient();
HttpClients.createSocketClient(boolean keepAlive);
HttpClients.createSocketClient(int connectTimeout);
HttpClients.createSocketClient(int connectTimeout, boolean keepAlive);
// variants of HttpURLConnection implementation:
HttpClients.createClient(ExecutorService);
HttpClients.createClient(ExecutorService, int connectTimeout);
HttpClients.createClient(ExecutorService, int connectTimeout, int readTimeout);
HttpClients.createClient();
// variants of binary http-client wrappers implementation:
HttpClients.binary(HttpClient httpClient, Reader reader);
HttpClients.binary(HttpClient httpClient, InputStream inputStream);
HttpClients.binary(HttpClient httpClient, File file) throws IOException;
HttpClients.binary(HttpClient httpClient, Path path) throws IOException;
Suppose we decide to implement a Socket connection with the ability
to make requests asynchronously, and we set its connect-timeout = 3000ms
,
and keep-alive = false
to automatically close the socket after the request is executed.
Example:
HttpClient httpClient = HttpClients.createSocketClient(
Executors.newCachedThreadPool(), connectTimeout, keepAlive);
Next, we can already call any of more than a hundred functions
to fulfill the request and get an instant response.
For example, send a GET request to the public web page https://catfact.ninja/fact
,
from where we will get the result as JSON with a random fact about cats :D
Example:
httpClient.executeGet("https://catfact.ninja/fact")
.ifPresent(response -> {
HttpProtocol protocol = response.getProtocol(); // HTTP/1.1
String statusLine = response.getHeaders().getFirst(null); // HTTP/1.1 200 OK
ResponseCode responseCode = response.getCode();
if (!responseCode.isSuccessful()) {
throw new RuntimeException("Content not found - " + responseCode);
}
System.out.println(httpResponse.getContent().getText());
// {"fact":"A cat usually has about 12 whiskers on each side of its face.","length":61}
});
The client API also implements one cool thing, thanks to which
you can simplify the implementation of HTTP requests as much
as possible by writing just a few words in the code to do it!
BINARY FILES
Basic information you need to know when writing a binary:
The first lines are general Properties that can be applied in the queries themselves.
The most important among them is the host = ...
line.
It is mandatory in application, and indicates the main address part of the URL that will be accessed.
Next after Properties are the functions.
Their structure is described by the following signature:
<name>: <METHOD> /<URI> {
...
}
The content of the function is divided into several keywords
that can be used within the body of the function:
- head: One of the headings of the query
- attr: URI attributes that will be appended to the URL with a '?' (e.g. /employee?id=1, where 'id' is an attribute)
-
body: Request body
- length: The size of the body to be sent under the guise of the 'Content-Length' header
- type: The body type that will be sent under the 'Content-Type' header appearance
- text: Header content as Hyper text
The values that come after the keyword are mostly
in the Properties format.
Example binary (/catfacts.restbin
):
host = https://catfact.ninja/
randomCatFact = A cat usually has about 12 whiskers on each side of its face.
userAgent = JRest-Binary
contentType = application/json
getFact: GET /fact {
head User-Agent = ${userAgent}
head Accept = text/plain
attr length = 50
}
createFact: POST /fact {
head User-Agent = ${userAgent}
body {
type = ${contentType}
text = {"fact": "${randomCatFact}", "length": 61}
}
}
After successfully writing our binary, we can start executing it by first creating a BinaryHttpClient
via the factory: HttpClients.createBinaryClient(HttpClient, <path-to-binary>)
BinaryHttpClient has 2 additional methods that distinguish
it from other HTTP clients: executeBinary(name)
and executeBinaryAsync(name)
.
Example (Java Client):
BinaryHttpClient httpClient = HttpClients.binary(
HttpClients.createClient(),
getClass().getResourceAsStream("/catfacts.restbin"));
httpClient.executeBinary("getFact")
.ifPresent(httpResponse -> {
HttpProtocol protocol = response.getProtocol(); // HTTP/1.1
String statusLine = response.getHeaders().getFirst(null); // HTTP/1.1 200 OK
ResponseCode responseCode = response.getCode();
if (!responseCode.isSuccessful()) {
throw new RuntimeException("Content not found - " + responseCode);
}
System.out.println(httpResponse.getContent().getText());
// {"fact":"A cat usually has about 12 whiskers on each side of its face.","length":61}
});
And also for executing binary functions you can use input properties to
customize the request from the outside.
Here is an example.
Example (binary with inputs):
host = http://localhost:8080/
get_employee: GET /employee {
attr id = ${input.employee_id}
}
post_employee: POST /employee {
body {
text = ${input.employee}
}
}
Here we can notice the ${input.employee_id}
property, we expect
to get it from the client.
Below I will give an example of applying it to an executable file.
Example (Java Client):
BinaryHttpClient httpClient = HttpClients.binary(
HttpClients.createClient(),
HttpClientBinaryUrlTest.class.getResourceAsStream("/employee.restbin"));
httpClient.executeBinary("get_employee",
Attributes.newAttributes().with("employee_id", 567))
.ifPresent(httpResponse -> {
System.out.println(httpResponse.getContent().getText());
// {"id":567,"firstName":"Piter","lastName":"Harrison","jobInfo":{"company":"Microsoft Corporation","website":"https://www.microsoft.com/","profession":"Developer C#","salary":3500}}
});
SERVERS
To create a server and initialize it, things are a bit more complicated,
but only because it is a server, and it needs full business logic.
Let's start with the simplest creation of the server as an object,
form it from the parameters we need:
Example:
HttpServer httpServer = HttpServer.builder()
.build();
Several components are required to properly initialize the server,
each of which affects a specific part of the software part:
PARAMETER TYPE | USAGE EXAMPLE | DESCRIPTION |
---|---|---|
InetSocketAddress | .socketAddress(new InetSocketAddress(80)) |
Server bindings address and port. |
ExecutorService | .executorService(Executors.newCachedThreadPool()) |
Service to execute threads, if not specified, a cached thread pool is used. (CachedThreadPool is used by default if null is specified) |
HttpProtocol | .protocol(HttpProtocol.HTTP_1_0) |
HTTP protocol, by default HTTP/1.1. |
SslContent | .ssl(SslContent.builder()...) |
SSL settings for HTTPS, if null, HTTP is used. |
HttpListener | .notFoundListener(httpRequest -> ...) |
Listener to handle requests that have not found an appropriate handler. (404 Not Found) |
Now based on this information let's try to implement a server
that supports HTTP/1.1 protocol without SSL certificates
Example;
HttpServer httpServer = HttpServer.builder()
.socketAddress(new InetSocketAddress(8080))
.build();
httpServer.bind();
We can now intercept requests that come to us by skipping or sending
back some kind of response. Request listeners can be either asynchronous
or synchronous.
Examples:
httpServer.registerListener(httpRequest -> {
System.out.println(httpRequest);
return HttpResponse.ok();
});
httpServer.registerAsyncListener("/employee", httpRequest ->
HttpResponse.ok(Content.fromEntity(
Employee.builder()
.id(567))
.jobInfo(EmployeeJob.builder()
.company("Microsoft Corporation")
.website("https://www.microsoft.com/")
.profession("Developer C#")
.salary(3500)
.build())
.firstName("Piter")
.lastName("Harrison")
.build())));
Realizing perfectly well that handling each such request in the form of
registering them through listeners would not be entirely convenient,
especially in the case where there may be quite a few endpoints.
Therefore, the MVC module was implemented, providing a more flexible
and readable implementation of HTTP requests interception.
For the example, let's create an instance that will be a repository
of HTTP requests for our server and register it:
@HttpServer
public class EmployeesHttpRepository {
}
HttpServer httpServer = HttpServer.builder()
.socketAddress(new InetSocketAddress(8080))
.build();
httpServer.registerRepository(new EmployeesHttpRepository()); // <----
httpServer.bind();
Now we can proceed to the nuances of its further construction,
because this is where the most interesting things begin!
To implement some endpoint, we have several annotations that
allow us to do so:
- @HttpRequestMapping
- @HttpGet
- @HttpPost
- @HttpDelete
- @HttpPut
- @HttpPatch
- @HttpConnect
- @HttpHead
- @HttpOptions
- @HttpTrace
Let's start with the simplest one and implement the processing
of GET request to the path /employee with the possibility of
specifying the identifier of the Employee we need
through attributes (for example, /employee?id=567)
Example:
@HttpServer
public class EmployeesHttpRepository {
@HttpGet("/employee")
public HttpResponse getEmployee(HttpRequest request) {
Attributes attributes = request.getAttributes();
Optional<Integer> attributeIdOptional = attributes.getInteger("id");
if (!attributeIdOptional.isPresent()) {
return ...;
}
return HttpResponse.ok(Content.fromEntity(
Employee.builder()
.id(attributeIdOptional.get())
.jobInfo(EmployeeJob.builder()
.company("Microsoft Corporation")
.website("https://www.microsoft.com/")
.profession("Developer C#")
.salary(3500)
.build())
.firstName("Piter")
.lastName("Harrison")
.build()));
}
}
Now, suppose in the line where we check for the passed
attribute !attributeIdOptional.isPresent()
we need to pass
the processing of this request to the NotFoundListener that
was specified when HttpServer was initialized.
To do this, we need to return the HttpListener.SKIP_ACTION
constant:
if (!attributeIdOptional.isPresent()) {
return HttpListener.SKIP_ACTION;
}
But in this case it would be more correct to return
a 400 Bad Request
error, and for this we can call
the function from HttpResponse in one of two ways:
if (!attributeIdOptional.isPresent()) {
return HttpResponse.builder()
.code(ResponseCode.BAD_REQUEST)
.build();
}
or just:
if (!attributeIdOptional.isPresent()) {
return HttpResponse.badRequest();
}
Now when we query the http://localhost:8080/employee?id=567
page, we get the following result:
ADDITIONAL HTTP-SERVER FEATURES
But that's not all!
The HTTP server repository has several other features that add
some flexibility and convenience in exceptional cases of library use.
Let's go through some of them!
Annotation @HttpBeforeExecution:
Annotation allows you to pre-validate an incoming request,
change some parameters, or perform additional processes before
processing:
Example:
@HttpBeforeExecution
public void before(HttpRequest httpRequest) {
httpRequest.setHeaders(
httpRequest.getHeaders()
.set(Headers.Def.USER_AGENT, "Mikhail Sterkhov")
);
}
Annotation @HttpAsync:
You can hang this annotation on literally any method that handles queries.
It implements some kind of wrapper of the handler in separate threads,
if it is really necessary for the implementation.
Example:
@HttpAsync
@HttpPatch("/employee")
public HttpResponse patchEmployee(HttpRequest request) {
Employee employee = request.getContent().toEntity(Employee.class);
try {
employeesService.patch(employee);
return HttpResponse.ok();
}
catch (EmployeeException exception) {
return HttpResponse.internalError();
}
}
Annotation @HttpAuthenticator:
To verify requests via authorization, you can use a fully dedicated
functionality for this purpose, which provides you with an entire
model API to implement HTTP request authentication.
For examples:
Basic
private static final Token.UsernameAndPassword APPROVAL_TOKEN =
Token.UsernameAndPassword.of("jrest_admin", "password");
@HttpAuthenticator
public ApprovalResult approveAuth(UnapprovedRequest request) {
return request.basicAuthenticate(APPROVAL_TOKEN);
}
Bearer
private static final String GENERATED_API_TOKEN = TokenGenerator.defaults(30).generate();
@HttpAuthenticator
public ApprovalResult approveAuth(UnapprovedRequest request) {
return request.bearerAuthenticate(GENERATED_API_TOKEN);
}
Bearer (custom)
private static final String GENERATED_API_TOKEN = TokenGenerator.defaults(30).generate();
@HttpAuthenticator
public ApprovalResult approveAuth(UnapprovedRequest request) {
if (request.getAuthentication() != Authentication.BEARER) {
return ApprovalResult.forbidden();
}
HttpCredentials credentials = request.getRequestCredentials();
Token token = credentials.getToken();
if (Objects.equals(token.getValue(), GENERATED_API_TOKEN)) {
return ApprovalResult.approve();
}
return ApprovalResult.forbidden();
}
In case you want to apply authorization
without using classes with the @HttpServer annotation,
there are ways to do it too, let's look at a few of them:
HttpServer httpServer = ...;
Token.UsernameAndPassword credentials = Token.UsernameAndPassword.of("jrest_admin", "password")
httpServer.addAuthenticator(HttpBasicAuthenticator.of(credentials));
HttpServer httpServer = ...;
httpServer.addAuthenticator(HttpBearerAuthenticator.of(
Arrays.asList(
"c9636ffe984e41d7b03c1b42d72402210aa9e64f2bedd6064a70416ba5e",
"f9af04c492c35e468100f9eead215903a67cdc3168fd95d78ca9bd4f9173",
"fe332dc685090ddbbf1a7569f22ac2bbe0f13644dbcd3f77cbeaf8f86c47"));
HttpServer httpServer = ...;
httpServer.addAuthenticator(HttpBearerAuthenticator.single(
"c9636ffe984e41d7b03c1b42d72402210aa9e64f2bedd6064a70416ba5e"));
HttpServer httpServer = ...;
httpServer.addAuthenticator(Authentication.DIGEST, (unapprovedRequest) -> { return ApprovalResult.forbidden(); });
Annotation @HttpNotAuthorized:
With this annotation, you can mark methods with HTTP requests
to be excluded from the request authentication process.
Example:
@HttpNotAuthorized
@HttpGet("/employee")
public HttpResponse doGet(HttpRequest request) {
// request handle logic...
}
Conclusion
I hope the jrest2 library will be useful for your project. If you have any questions or suggestions, feel free to open an issue in the repository or contact me directly. Your feedback and contributions are always welcome!
Top comments (0)