-
Notifications
You must be signed in to change notification settings - Fork 0
Getting Started
- Project Setup and Creating a Simple Controller
- Implementing CRUD Operations with the Quak API Generator
- Quak Controllers
- Configuring CORS
- Role-based Access Control With JWT
In this guide, we'll go over how to create a simple Quak Application from scratch so you can get a gist of the typical workflow. For this guide we'll assume that you:
- have an intermediate to advanced understanding of Java
- have a basic understanding of Maven
- have an understanding of REST APIs
- understand the Java Hibernate framework for ORM
First, create a maven project and add quak.framework.core and quak.framework.web as dependencies of the project (refer to the installation guide to help you set this up). Then, create a class called Main like so:
package io.john.amiscaray.test;
import io.john.amiscaray.quak.web.application.WebStarter;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException, TimeoutException {
WebApplication application = WebStarter.beginWebApplication(Main.class, args)
.get(10, TimeUnit.SECONDS);
application.await();
}
}This starts a new web application and then calls the Application#await method to block while our web application hasn't terminated yet. Within the WebStarted.beginWebApplication method, Quak scans your project for classes (known as controllers) to use to handle HTTP requests. Let's have a look at what Quak expects these classes to look like:
package io.john.amiscaray.test.controllers;
import io.john.amiscaray.quak.http.request.Request;
import io.john.amiscaray.quak.http.request.RequestMethod;
import io.john.amiscaray.quak.http.response.Response;
import io.john.amiscaray.quak.web.controller.annotation.Controller;
import io.john.amiscaray.quak.web.handler.annotation.Handle;
@Controller
public class HelloWorldController {
@Handle(path="/greeting", method = RequestMethod.GET)
public Response<String> greet(Request<Void> request) {
return Response.of("Hello World!");
}
}Here, we create a simple method accepting a Request object and returning a Response object. The type argument passed to each object corresponds to a class representing the HTTP request/response body. So in this example, we expect a request without a request body (void) and return a text response.
Now, let's see how we can implement some basic CRUD operations. First, let's start off by creating a Hibernate entity:
package io.john.amiscaray.test.orm;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.*;
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String major;
private String name;
private Float gpa;
}For that entity, let's create a corresponding DTO class for representing this data in request/response bodies:
package io.john.amiscaray.test.models;
import io.john.amiscaray.test.orm.StudentTableEntry;
import lombok.*;
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Getter
public class StudentDTO {
private String name;
private String major;
private Float gpa;
}From here, we can go ahead and write a controller for the GET/POST/PATCH/PUT/DELETE operations ourselves but Quak can automate the creation of common REST endpoint patterns using a Maven plugin. First, add the following plugin to the plugins section of your project's pom.xml:
<plugin>
<groupId>io.john.amiscaray</groupId>
<artifactId>quak.framework.generator</artifactId>
<version>1.0-SNAPSHOT</version>
<executions>
<execution>
<goals>
<goal>generate-controllers</goal>
</goals>
<phase>process-sources</phase>
<configuration>
<rootPackage>io.john.amiscaray.test</rootPackage>
<targetPackage>io.john.amiscaray.test.controllers</targetPackage>
</configuration>
</execution>
</executions>
</plugin>The rootPackage argument corresponds to the package where your main class is located and the targetPackage argument is where you want your controllers to be generated. From there, add the following dependency:
<dependency>
<groupId>io.john.amiscaray</groupId>
<artifactId>quak.framework.generator-model</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>With that, we can make some minor edits to our entity and DTO classes so the plugin can figure out how to generate the controller:
package io.john.amiscaray.test.orm;
import io.john.amiscaray.quak.generator.api.ModelGenerator;
import io.john.amiscaray.test.models.StudentDTO;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.*;
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String major;
private String name;
private Float gpa;
public Student(String major, String name, Float gpa) {
this.major = major;
this.name = name;
this.gpa = gpa;
}
@ModelGenerator
public static StudentDTO toStudentDTO(Student student) {
return new StudentDTO(student.getMajor(), student.getName(), student.getGpa());
}
}package io.john.amiscaray.test.models;
import io.john.amiscaray.quak.generator.api.EntityGenerator;
import io.john.amiscaray.quak.generator.api.RestModel;
import io.john.amiscaray.test.orm.Student;
import lombok.*;
@RestModel(dataClass = Student.class)
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Getter
public class StudentDTO {
private String name;
private String major;
private Float gpa;
@EntityGenerator
public static Student toEntity(StudentDTO dto) {
return new Student(dto.major, dto.name, dto.gpa);
}
}Now, when running mvn install Quak will output the following controller added to your compiled sources:
package io.john.amiscaray.test.controllers;
import io.john.amiscaray.quak.core.di.provider.annotation.Instantiate;
import io.john.amiscaray.quak.http.request.DynamicPathRequest;
import io.john.amiscaray.quak.http.request.Request;
import io.john.amiscaray.quak.http.request.RequestMethod;
import io.john.amiscaray.quak.http.response.Response;
import io.john.amiscaray.test.models.StudentDTO;
import io.john.amiscaray.test.orm.Student;
import io.john.amiscaray.quak.data.DatabaseProxy;
import io.john.amiscaray.quak.web.controller.annotation.Controller;
import io.john.amiscaray.quak.web.handler.annotation.Handle;
import java.util.HashMap;
import java.util.List;
@Controller
public class StudentDTOController {
private DatabaseProxy databaseProxy;
@Instantiate
public StudentDTOController(DatabaseProxy databaseProxy) {
this.databaseProxy = databaseProxy;
}
@Handle(method = RequestMethod.POST, path = "/studentdto")
public Response<Void> saveStudentDTO(Request<StudentDTO> request) {
var studentdto = request.body();
var entity = StudentDTO.toEntity(studentdto);
databaseProxy.persist(entity);
var headers = new HashMap<String, String>();
headers.put("Location", "/studentdto/" + entity.getId());
return new Response(headers, 201, null);
}
@Handle(method = RequestMethod.GET, path = "/studentdto")
public Response<List<StudentDTO>> getAllStudentDTO(Request<Void> request) {
return Response.of(
databaseProxy.queryAll(Student.class)
.stream()
.map(Student::toStudentDTO)
.toList()
);
}
@Handle(method = RequestMethod.GET, path = "/studentdto/{id}Long")
public Response<StudentDTO> getStudentDTO(DynamicPathRequest<Void> request) {
try {
var id = Long.parseLong(request.pathVariables().get("id"));
var fetched = databaseProxy.fetchById(id, Student.class);
if (fetched == null) {
return new Response(404, null);
}
return Response.of(Student.toStudentDTO(fetched));
} catch (NumberFormatException e) {
return new Response(404, null);
}
}
@Handle(method = RequestMethod.DELETE, path = "/studentdto/{id}Long")
public Response<Void> deleteStudentDTO(DynamicPathRequest<Void> request) {
try {
var id = Long.parseLong(request.pathVariables().get("id"));
try {
databaseProxy.delete(id, Student.class);
} catch (IllegalArgumentException e) {
return new Response(404, null);
}
return new Response(204, null);
} catch (NumberFormatException e) {
return new Response(404, null);
}
}
@Handle(method = RequestMethod.PUT, path = "/studentdto/{id}Long")
public Response<Void> putStudentDTO(DynamicPathRequest<StudentDTO> request) {
try {
var id = Long.parseLong(request.pathVariables().get("id"));
var entity = StudentDTO.toEntity(request.body());
entity.setId(id);
var isUpdate = databaseProxy.put(entity, id, Student.class);
if (isUpdate) {
return new Response(204, null);
} else {
var headers = new HashMap<String, String>();
headers.put("Location", "/studentdto/" + entity.getId());
return new Response(headers, 201, null);
}
} catch (NumberFormatException e) {
return new Response(404, null);
}
}
@Handle(method = RequestMethod.PATCH, path = "/studentdto/{id}Long")
public Response<Void> patchStudentDTO(DynamicPathRequest<StudentDTO> request) {
try {
var id = Long.parseLong(request.pathVariables().get("id"));
var entity = StudentDTO.toEntity(request.body());
entity.setId(id);
var foundEntity = databaseProxy.patch(entity, id, Student.class);
if (foundEntity) {
return new Response(204, null);
} else {
return new Response(404, null);
}
} catch (NumberFormatException e) {
return new Response(404, null);
}
}
}As you can see, Quak generated some standard REST endpoints for fetching by ID, fetching all, posting new data, deleting by ID, and editing data. Using the code Quak generated, let's go over some key points on how controllers in Quak work:
As you can see from the generated controller and the controller we created at the start of this guide, Quak controllers are annotated using the @Controller annotation and contain methods accepting a Request object and returning a Response object. Each of these methods should be annotated with the @Handle annotation which specifies an HTTP method and a URL path. These URL paths are relative to a parent path that can be specified in the @Controller annotation of the class (this value defaults to an empty string meaning your methods' paths are relative to the root of your API). Additionally, you'll notice that in the generated code above, the class has a constructor annotated with an @Instantiate annotation. This is used for Quak's dependency injection framework to know how to instantiate this controller at runtime. When this gets instantiated, Quak uses a DatabaseProxy instance that it instantiates itself.
As for how the @Handle methods work, let's start with the first one that Quak generated for us:
@Handle(method = RequestMethod.POST, path = "/studentdto")
public Response<Void> saveStudentDTO(Request<StudentDTO> request) {
var studentdto = request.body();
var entity = StudentDTO.toEntity(studentdto);
databaseProxy.persist(entity);
var headers = new HashMap<String, String>();
headers.put("Location", "/studentdto/" + entity.getId());
return new Response(headers, 201, null);=
}Here, you can see that we can retrieve the request body using a Request#body method. In that example, we then use the StudentDTO#toEntity method we defined to convert the DTO passed through the request body into a corresponding Hibernate entity. Then, using the entity, we save it using the DatabaseProxy instance and return a response with a header containing the path to the newly created resource.
Now, let's look at the method implementing the endpoint for retrieving a student by ID:
@Handle(method = RequestMethod.GET, path = "/studentdto/{id}Long")
public Response<StudentDTO> getStudentDTO(DynamicPathRequest<Void> request) {
try {
var id = Long.parseLong(request.pathVariables().get("id"));
var fetched = databaseProxy.fetchById(id, Student.class);
if (fetched == null) {
return new Response(404, null);
}
return Response.of(Student.toStudentDTO(fetched));
} catch (NumberFormatException e) {
return new Response(404, null);
}
}You'll notice that the path passed to the @Handle annotation ends with a "{id}Long" subpath. This means that the path ends with a path variable named "id" of type Long. We can retrieve the value of this path variable using the request.pathVariables().get("id") statement above. Using this ID, we can use the DatabaseProxy instance to query the database for a student with that ID.
Now, the next most important step is to configure CORS so our backend can be called from a web application on a browser. To do this first, we need to add the quak.framework.security dependency:
<dependency>
<groupId>io.john.amiscaray</groupId>
<artifactId>quak.framework.security</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>Additionally, you'll notice that alongside our controller, Quak also generated for us a module-info.java file (see this for info on java modules). To properly use this security module, we need our module-info.java to declare that we are using it. To do so, we can create a resources/module-info.template file containing that declaration:
module io.john.amiscaray.test {
requires quak.framework.security;
}
Now, when Quak creates the module-info.java file, it will use this as a template so it will insert its own declarations below ours:
module io.john.amiscaray.test {
requires quak.framework.security;
// GENERATED SOURCES:
exports io.john.amiscaray.test.controllers to quak.framework.core, quak.framework.web;
// Rules for RestModels
opens io.john.amiscaray.test.models to com.fasterxml.jackson.databind;
// Rules for Entities
opens io.john.amiscaray.test.orm to org.hibernate.orm.core;
// Rules for DI Components
opens io.john.amiscaray.test.security to quak.framework.core;
requires quak.framework.core;
requires quak.framework.data;
requires quak.framework.generator.model;
requires quak.framework.web;
requires quak.framework.web.model;
requires jakarta.persistence;
requires static lombok;
requires org.reflections;
}With that setup out of the way, let's actually configure CORS. To do so, we need to provide Quak with a SecurityConfig instance it will use to configure our API's security. To do so, we can use Quak's dependency injection framework:
package io.john.amiscaray.test.security;
import io.john.amiscaray.quak.core.di.provider.annotation.Provide;
import io.john.amiscaray.quak.core.di.provider.annotation.Provider;
import io.john.amiscaray.quak.security.config.CORSConfig;
import io.john.amiscaray.quak.security.config.SecurityConfig;
import io.john.amiscaray.quak.security.di.SecurityDependencyIDs;
import java.util.List;
@Provider
public class SecurityConfigProvider {
@Provide(dependencyName = SecurityDependencyIDs.SECURITY_CONFIG_DEPENDENCY_NAME)
public SecurityConfig provideSecurityConfig() {
return SecurityConfig
.builder()
.securePathWithCorsConfig("/*", CORSConfig.builder()
.allowOrigin("http://localhost:4200")
.allowAllHeaders(true)
.allowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"))
.build())
.build();
}
}Using the @Provider annotation, we declare that we will use this class to provide a dependency to Quak's ApplicationContext (the class responsible for holding all of our application's dependencies). Then, we declare a method using the @Provide method which declares a dependencyName which Quak will use to know this is the SecurityConfig to use for our application. This method then returns a SecurityConfig instance which allows CORS for all endpoints to a "http://localhost:4200" host.
Now, let's start securing our API using a role-based access control strategy using JWT. First, let's create a class to define what roles we'll use for our application:
package io.john.amiscaray.test.security;
import io.john.amiscaray.quak.security.auth.principal.role.Role;
public class Roles {
public static Role user() {
return () -> "USER";
}
public static Role admin() {
return () -> "ADMIN";
}
}Now, let's create an implementation of the Authenticator interface provided to us by quak.framework.security. For this example, we'll use hard-coded users but in the real world, you'd need to use a DatabaseProxy instance to query for users and validate them:
package io.john.amiscaray.test.security;
import io.john.amiscaray.quak.core.di.provider.annotation.ManagedType;
import io.john.amiscaray.quak.security.auth.Authenticator;
import io.john.amiscaray.quak.security.auth.credentials.Credentials;
import io.john.amiscaray.quak.security.auth.principal.Principal;
import io.john.amiscaray.quak.security.auth.principal.RoleAttachedPrincipal;
import io.john.amiscaray.quak.security.auth.principal.role.Role;
import io.john.amiscaray.quak.security.di.SecurityDependencyIDs;
import java.util.Optional;
@ManagedType(dependencyName = SecurityDependencyIDs.AUTHENTICATOR_DEPENDENCY_NAME, dependencyType = Authenticator.class)
public class SimpleAuthenticator implements Authenticator {
private static final RoleAttachedPrincipal JOHN = new RoleAttachedPrincipal() {
@Override
public Role[] getRoles() {
return new Role[] { Roles.user() };
}
@Override
public String getSecurityID() {
return "Johnny Boy";
}
};
private static final RoleAttachedPrincipal ELLI = new RoleAttachedPrincipal() {
@Override
public Role[] getRoles() {
return new Role[] { Roles.admin() };
}
@Override
public String getSecurityID() {
return "Elli";
}
};
@Override
public Optional<Principal> lookupPrincipal(String s) {
if (s.equals(JOHN.getSecurityID())) {
return Optional.of(JOHN);
} else if (s.equals(ELLI.getSecurityID())) {
return Optional.of(ELLI);
}
return Optional.empty();
}
@Override
public Optional<Principal> lookupPrincipal(Credentials credentials) {
if (credentials.getUsername().equals("John") && credentials.getPassword().equals("password")) {
return Optional.of(JOHN);
} else if (credentials.getUsername().equals("Elli") && credentials.getPassword().equals("password")) {
return Optional.of(ELLI);
}
return Optional.empty();
}
}Here, we implement two methods from the Authenticator interface: Authenticator#lookupPrincipal accepting a String (the user's security ID), and Authenticator#lookupPrincipal accepting a Credentials object. Each of these methods validate a given security ID or credentials and returns either an Optional containing a Principal implementation used to identify the user, or an empty Optional if the credentials are invalid. With that, we can update our SecurityConfig:
package io.john.amiscaray.test.security;
import io.john.amiscaray.quak.core.di.provider.annotation.Provide;
import io.john.amiscaray.quak.core.di.provider.annotation.Provider;
import io.john.amiscaray.quak.security.config.CORSConfig;
import io.john.amiscaray.quak.security.config.EndpointMapping;
import io.john.amiscaray.quak.security.config.SecurityConfig;
import io.john.amiscaray.quak.security.di.AuthenticationStrategy;
import io.john.amiscaray.quak.security.di.SecurityDependencyIDs;
import java.util.List;
@Provider
public class SecurityConfigProvider {
@Provide(dependencyName = SecurityDependencyIDs.SECURITY_CONFIG_DEPENDENCY_NAME)
public SecurityConfig provideSecurityConfig() {
return SecurityConfig
.builder()
.authenticationStrategy(AuthenticationStrategy.JWT)
.securePathWithRole(new EndpointMapping(
"/studentdto/*",
List.of(EndpointMapping.RequestMethodMatcher.ANY_MODIFYING)
), List.of(Roles.admin()))
.securePathWithCorsConfig("/*", CORSConfig.builder()
.allowOrigin("http://localhost:4200")
.allowAllHeaders(true)
.allowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"))
.build())
.build();
}
}The .authenticationStrategy(AuthenticationStrategy.JWT) statement specifies in our SecurityConfig that Quak will use an authentication filter expecting a JWT token in the header. Then the call to the securePathWithRole method specifies that any request matching the "/studentdto/*" path with HTTP methods for a modifying operation (i.e., POST, PATCH, PUT, DELETE) will be secured so only admins can use them. Now we need a controller for issuing our JWTs:
package io.john.amiscaray.test.controllers;
import io.john.amiscaray.quak.core.di.provider.annotation.Instantiate;
import io.john.amiscaray.quak.http.request.Request;
import io.john.amiscaray.quak.http.request.RequestMethod;
import io.john.amiscaray.quak.http.response.Response;
import io.john.amiscaray.quak.security.auth.Authenticator;
import io.john.amiscaray.quak.security.auth.credentials.Credentials;
import io.john.amiscaray.quak.security.auth.exception.InvalidCredentialsException;
import io.john.amiscaray.quak.security.auth.jwt.JwtUtil;
import io.john.amiscaray.quak.web.controller.annotation.Controller;
import io.john.amiscaray.quak.web.handler.annotation.Handle;
import io.john.amiscaray.test.models.LoginRequestBody;
@Controller
public class JWTIssuerController {
private JwtUtil jwtUtil;
private Authenticator authenticator;
@Instantiate
public JWTIssuerController(JwtUtil jwtUtil, Authenticator authenticator) {
this.jwtUtil = jwtUtil;
this.authenticator = authenticator;
}
@Handle(path="/login", method = RequestMethod.POST)
public Response<String> login(Request<LoginRequestBody> request) {
var requestBody = request.body();
var credentials = new Credentials() {
@Override
public String getUsername() {
return requestBody.username();
}
@Override
public String getPassword() {
return requestBody.password();
}
};
try {
var authentication = authenticator.authenticate(credentials);
var jwt = jwtUtil.generateToken(authentication.getIssuedTo());
return Response.of(jwt);
} catch (InvalidCredentialsException e) {
return new Response<>(401, "Invalid credentials");
}
}
}Here, we inject our Authenticator instance and a JWTUtil instance provided to us by Quak. With that, our API should now be secured with JWT tokens required in the headers for any requests that modify resources. If we were to run this example, only the admin user will have access to these endpoints.
With that, you should have a decent understanding of the fundamentals you need to use this framework. Next, try exploring the quak.framework.data module to understand how to write data queries and try making an API of your own with Quak!