API-first with openapiprocessor and modelmapper

This commit is contained in:
Michael Hoennig 2022-08-08 16:54:35 +02:00
parent 80f342eeae
commit eeab68d63a
14 changed files with 602 additions and 62 deletions

View File

@ -1,6 +1,7 @@
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.2'
id 'io.openapiprocessor.openapi-processor' version '2021.3'
id 'io.spring.dependency-management' version '1.0.12.RELEASE'
id 'com.github.jk1.dependency-license-report' version '2.1'
id "org.owasp.dependencycheck" version "7.1.1"
@ -45,6 +46,8 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-ui:1.6.9'
implementation 'org.liquibase:liquibase-core'
implementation 'com.vladmihalcea:hibernate-types-55:2.17.1'
implementation 'org.openapitools:jackson-databind-nullable:0.2.3'// https://mvnrepository.com/artifact/org.modelmapper/modelmapper
implementation 'org.modelmapper:modelmapper:3.1.0'
compileOnly 'org.projectlombok:lombok'
@ -75,11 +78,29 @@ tasks.named('test') {
useJUnitPlatform()
}
openapiProcessor {
spring {
processor 'io.openapiprocessor:openapi-processor-spring:2021.4'
apiPath "$projectDir/src/main/resources/api-definition.yaml"
targetDir "$projectDir/build/generated/sources/openapi"
mapping "$projectDir/src/main/resources/api-mappings.yaml"
showWarnings true
}
}
sourceSets.main.java.srcDir 'build/generated/sources/openapi'
compileJava.dependsOn('processSpring')
spotless {
java {
removeUnusedImports()
endWithNewline()
toggleOffOn()
// target 'src/main/java**/*.java', 'src/test/java**/*.java' // not generated
target project.fileTree(project.rootDir) {
include '**/*.java'
exclude '**/generated/**/*.java'
}
}
}
project.tasks.check.dependsOn(spotlessCheck)

15
doc/adr-concept.md Normal file
View File

@ -0,0 +1,15 @@
## ADR-Concept
This project uses ADRs (Architecture Decision Records), see also https://adr.github.io/.
There is a template available under [0000-00-00.adr-tempate.md](./0000-00-00.adr-tempate.md).
It's suggested to write an ADR if any of these is true:
- an architectural decision is hard to change,
- there is a dispute about an architectural decision,
- some unusual architectural decision was made (e.g. unusual library),
- some deeper investigation was necessary before the decision.
ADRs should not be written for minor decisions with limited impact.

View File

@ -0,0 +1,39 @@
# TITLE
**Status:**
- [ ] proposed by (Proposer)
- [ ] accepted by (Participants)
- [ ] rejected by (Participants)
- [ ] superseded by (superseding ADR)
## Context and Problem Statement
A short description, why and under which circumstances this decision had to be made.
### Technical Background
Some details about the technical challenge.
## Considered Options
* OPTION-1
* OPTION-...
### OPTION-n
A short overview about the option.
#### Advantages
A list of advantages.
#### Disadvantages
A list of disadvantages.
## Decision Outcome
Which option was chose and why.

View File

@ -0,0 +1,108 @@
# Object Mapping
**Status:**
- [x] proposed by Michael Hönnig
- [ ] accepted by (Participants)
- [ ] rejected by (Participants)
- [ ] superseded by (superseding ADR)
## Context and Problem Statement
Since we are using the *API first*-approach,
thus generating Java interfaces and model classes from an OpenAPI specification,
we cannot use the JPA-entities anymore at the API level,
not even if the data fields are 100% identical.
Therefore, we need some kind of mapping strategy.
### Technical Background
Java does not support duck-typing and therefore, objects of different classes have to be converted to each other, even if all data fields are identical.
In our case, the database query is usually the slowest part of handling a request.
Therefore, for the mapper, ease of use is more important than performance,
at least as long as the mapping part does not take more than 10% of the total request.
## Considered Options
* specific programmatic conversion
* using the *MapStruct* library
* using the *ModelMapper* library
* Dozer, last update from 2014 + vulnerabilities => skipped
* Orika, last update from 2019 + vulnerabilities => skipped
* JMapper
### specific programmatic conversion
In this solution, we would write own code to convert the objects.
This usually means 3 converters for each entity/resource pair:
- entity -> resource
- resource -> entity
- list of entities -> list of resources
#### Advantages
Very flexible and fast.
#### Disadvantages
Huge amounts of bloat code.
### using the *MapStruct* library
See https://mapstruct.org/.
#### Advantages
- Most popular mapping library in the Java-world.
- Actively maintained, last release 1.5.2 from Jun 18, 2022.
- very fast (see [^1])
#### Disadvantages
- Needs interface declarations with annotations.
- Looks like it causes still too much bloat code for our purposes.
### using the *ModelMapper* library
See http://modelmapper.org/.
#### Advantages
- 1:1 mappings just need a simple method call without any bloat-code.
- Actively maintained, last release 3.1.0 from Mar 08, 2022.
#### Disadvantages
- could not find any, will give it a try
### using the *JMapper* library
See https://jmapper-framework.github.io/jmapper-core/.
#### Advantages
- Supports annotation-based and programmatic mapping exceptions.
- Actively maintained, last release 1.6.3 from May 27, 2022.
- very fast (see [^1])
#### Disadvantages
- needs a separate mapper instance for each mapping pair
- cannot map collections (needs `stream().map(...).collect(toList())` or similar)
## Decision Outcome
We chose the option **"using the *ModelMapper* library"** because it has an acceptable performance without any bloat code.
If it turns out to be too slow after all, "using the *JMapper* library" seems to be a good alternative.
[^1]: https://www.baeldung.com/java-performance-mapping-frameworks

View File

@ -0,0 +1,27 @@
package net.hostsharing.hsadminng;
import org.modelmapper.ModelMapper;
import java.util.List;
import java.util.stream.Collectors;
/**
* A nicer API for ModelMapper.
*
* MOst
*/
public class Mapper {
private final static ModelMapper modelMapper = new ModelMapper();
public static <S, T> List<T> mapList(final List<S> source, final Class<T> targetClass) {
return source
.stream()
.map(element -> modelMapper.map(element, targetClass))
.collect(Collectors.toList());
}
public static <S, T> T map(final S source, final Class<T> targetClass) {
return modelMapper.map(source, targetClass);
}
}

View File

@ -1,16 +1,22 @@
package net.hostsharing.hsadminng.hs.hscustomer;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.generated.api.v1.api.CustomersApi;
import net.hostsharing.hsadminng.generated.api.v1.model.CustomerResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import javax.transaction.Transactional;
import java.util.List;
import java.util.UUID;
import static net.hostsharing.hsadminng.Mapper.map;
import static net.hostsharing.hsadminng.Mapper.mapList;
@RestController
public class CustomerController {
public class CustomerController implements CustomersApi {
@Autowired
private Context context;
@ -18,34 +24,40 @@ public class CustomerController {
@Autowired
private CustomerRepository customerRepository;
@GetMapping(value = "/api/customers")
@Override
@Transactional
public List<CustomerEntity> listCustomers(
@RequestHeader(value = "current-user") String userName,
@RequestHeader(value = "assumed-roles", required = false) String assumedRoles,
@RequestParam(required = false) String prefix
public ResponseEntity<List<CustomerResource>> listCustomers(
String userName,
String assumedRoles,
String prefix
) {
context.setCurrentUser(userName);
if (assumedRoles != null && !assumedRoles.isBlank()) {
context.assumeRoles(assumedRoles);
}
return customerRepository.findCustomerByOptionalPrefixLike(prefix);
return ResponseEntity.ok(
mapList(
customerRepository.findCustomerByOptionalPrefixLike(prefix),
CustomerResource.class));
}
@PostMapping(value = "/api/customers")
@Override
@Transactional
public CustomerEntity addCustomer(
@RequestHeader(value = "current-user") String userName,
@RequestHeader(value = "assumed-roles", required = false) String assumedRoles,
@RequestBody CustomerEntity customer
) {
context.setCurrentUser(userName);
public ResponseEntity<CustomerResource> addCustomer(
final String currentUser,
final String assumedRoles,
final CustomerResource customer) {
context.setCurrentUser(currentUser);
if (assumedRoles != null && !assumedRoles.isBlank()) {
context.assumeRoles(assumedRoles);
}
if (customer.getUuid() == null) {
customer.setUuid(UUID.randomUUID());
}
return customerRepository.save(customer);
return ResponseEntity.ok(
map(
customerRepository.save(map(customer, CustomerEntity.class)),
CustomerResource.class));
}
}

View File

@ -1,14 +1,19 @@
package net.hostsharing.hsadminng.hs.hspackage;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.generated.api.v1.api.PackagesApi;
import net.hostsharing.hsadminng.generated.api.v1.model.PackageResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import javax.transaction.Transactional;
import java.util.List;
import static net.hostsharing.hsadminng.Mapper.mapList;
@RestController
public class PackageController {
public class PackageController implements PackagesApi {
@Autowired
private Context context;
@ -16,18 +21,19 @@ public class PackageController {
@Autowired
private PackageRepository packageRepository;
@RequestMapping(value = "/api/packages", method = RequestMethod.GET)
@Override
@Transactional
public List<PackageEntity> listPackages(
@RequestHeader(value = "current-user") String userName,
@RequestHeader(value = "assumed-roles", required = false) String assumedRoles,
@RequestParam(required = false) String name
public ResponseEntity<List<PackageResource>> listPackages(
String userName,
String assumedRoles,
String name
) {
context.setCurrentUser(userName);
if (assumedRoles != null && !assumedRoles.isBlank()) {
context.assumeRoles(assumedRoles);
}
return packageRepository.findAllByOptionalNameLike(name);
final var result = packageRepository.findAllByOptionalNameLike(name);
return ResponseEntity.ok(mapList(result, PackageResource.class));
}
}

View File

@ -1,16 +1,20 @@
package net.hostsharing.hsadminng.rbac.rbacrole;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.generated.api.v1.api.RbacrolesApi;
import net.hostsharing.hsadminng.generated.api.v1.model.RbacRoleResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import javax.transaction.Transactional;
import java.util.List;
import static net.hostsharing.hsadminng.Mapper.mapList;
@RestController
public class RbacRoleController {
public class RbacRoleController implements RbacrolesApi {
@Autowired
private Context context;
@ -18,17 +22,13 @@ public class RbacRoleController {
@Autowired
private RbacRoleRepository rbacRoleRepository;
@GetMapping(value = "/api/rbacroles")
@Override
@Transactional
public Iterable<RbacRoleEntity> listCustomers(
@RequestHeader(value = "current-user") String userName,
@RequestHeader(value = "assumed-roles", required = false) String assumedRoles
) {
context.setCurrentUser(userName);
public ResponseEntity<List<RbacRoleResource>> listRoles(final String currentUser, final String assumedRoles) {
context.setCurrentUser(currentUser);
if (assumedRoles != null && !assumedRoles.isBlank()) {
context.assumeRoles(assumedRoles);
}
return rbacRoleRepository.findAll();
return ResponseEntity.ok(mapList(rbacRoleRepository.findAll(), RbacRoleResource.class));
}
}

View File

@ -6,14 +6,20 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.generated.api.v1.api.RbacusersApi;
import net.hostsharing.hsadminng.generated.api.v1.model.RbacUserPermissionResource;
import net.hostsharing.hsadminng.generated.api.v1.model.RbacUserResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.util.List;
import static net.hostsharing.hsadminng.Mapper.mapList;
@RestController
public class RbacUserController {
public class RbacUserController implements RbacusersApi {
@Autowired
private Context context;
@ -21,18 +27,9 @@ public class RbacUserController {
@Autowired
private RbacUserRepository rbacUserRepository;
@GetMapping(value = "/api/rbacusers")
@Operation(description = "List accessible RBAC users with optional filter by name.",
responses = {
@ApiResponse(responseCode = "200",
content = @Content(array = @ArraySchema(
schema = @Schema(implementation = RbacUserEntity.class)))),
@ApiResponse(responseCode = "401",
description = "if the 'current-user' cannot be identified"),
@ApiResponse(responseCode = "403",
description = "if the 'current-user' is not allowed to assume any of the roles from 'assumed-roles'") })
@Override
@Transactional
public List<RbacUserEntity> listUsers(
public ResponseEntity<List<RbacUserResource>> listUsers(
@RequestHeader(name = "current-user") String currentUserName,
@RequestHeader(name = "assumed-roles", required = false) String assumedRoles,
@RequestParam(name="name", required = false) String userName
@ -41,19 +38,12 @@ public class RbacUserController {
if (assumedRoles != null && !assumedRoles.isBlank()) {
context.assumeRoles(assumedRoles);
}
return rbacUserRepository.findByOptionalNameLike(userName);
return ResponseEntity.ok(mapList(rbacUserRepository.findByOptionalNameLike(userName), RbacUserResource.class));
}
@GetMapping(value = "/api/rbacuser/{userName}/permissions")
@Operation(description = "List all visible permissions granted to the given user; reduced ", responses = {
@ApiResponse(responseCode = "200",
content = @Content(array = @ArraySchema( schema = @Schema(implementation = RbacUserPermission.class)))),
@ApiResponse(responseCode = "401",
description = "if the 'current-user' cannot be identified"),
@ApiResponse(responseCode = "403",
description = "if the 'current-user' is not allowed to view permissions of the given user") })
@Override
@Transactional
public List<RbacUserPermission> listUserPermissions(
public ResponseEntity<List<RbacUserPermissionResource>> listUserPermissions(
@RequestHeader(name = "current-user") String currentUserName,
@RequestHeader(name = "assumed-roles", required = false) String assumedRoles,
@PathVariable(name= "userName") String userName
@ -62,6 +52,6 @@ public class RbacUserController {
if (assumedRoles != null && !assumedRoles.isBlank()) {
context.assumeRoles(assumedRoles);
}
return rbacUserRepository.findPermissionsOfUser(userName);
return ResponseEntity.ok(mapList(rbacUserRepository.findPermissionsOfUser(userName), RbacUserPermissionResource.class));
}
}

View File

@ -0,0 +1,306 @@
openapi: 3.0.1
info:
title: Hostsharing hsadmin-ng API
version: v0
servers:
- url: http://localhost:8080
description: Local development default URL.
paths:
/api/customers:
get:
summary: Returns a list of (optionally filtered) customers.
description: Returns the list of (optionally filtered) customers which are visible to the current user or any of it's assumed roles.
tags:
- customers
operationId: listCustomers
parameters:
- $ref: '#/components/parameters/currentUser'
- $ref: '#/components/parameters/assumedRoles'
- name: prefix
in: query
required: false
schema:
type: string
description: Customer-prefix to filter the results.
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: '#/components/schemas/Customer'
"401":
description: Not Authorized
"403":
description: Forbidden
post:
summary: Adds a new customer.
tags:
- customers
operationId: addCustomer
parameters:
- $ref: '#/components/parameters/currentUser'
- $ref: '#/components/parameters/assumedRoles'
requestBody:
content:
'application/json':
schema:
$ref: '#/components/schemas/Customer'
required: true
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: '#/components/schemas/Customer'
/api/rbac-users:
get:
tags:
- rbacusers
description: List accessible RBAC users with optional filter by name.
operationId: listUsers
parameters:
- $ref: '#/components/parameters/currentUser'
- $ref: '#/components/parameters/assumedRoles'
- name: name
in: query
required: false
schema:
type: string
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: '#/components/schemas/RbacUser'
"401":
description: if the 'current-user' cannot be identified
content:
'application/json':
schema:
type: array
items:
$ref: '#/components/schemas/RbacUser'
"403":
description: if the 'current-user' is not allowed to assume any of the roles
from 'assumed-roles'
content:
'application/json':
schema:
type: array
items:
$ref: '#/components/schemas/RbacUser'
/api/rbac-users/{userName}/permissions:
get:
tags:
- rbacusers
description: 'List all visible permissions granted to the given user; reduced '
operationId: listUserPermissions
parameters:
- $ref: '#/components/parameters/currentUser'
- $ref: '#/components/parameters/assumedRoles'
- name: userName
in: path
required: true
schema:
type: string
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: '#/components/schemas/RbacUserPermission'
"401":
$ref: '#/components/responses/Unauthorized'
"403":
$ref: '#/components/responses/Forbidden'
/api/rbac-roles:
get:
tags:
- rbacroles
operationId: listRoles
parameters:
- $ref: '#/components/parameters/currentUser'
- $ref: '#/components/parameters/assumedRoles'
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: '#/components/schemas/RbacRole'
/api/ping:
get:
tags:
- test
operationId: ping
responses:
"200":
description: OK
content:
'application/json':
schema:
type: string
/api/packages:
get:
tags:
- packages
operationId: listPackages
parameters:
- $ref: '#/components/parameters/currentUser'
- $ref: '#/components/parameters/assumedRoles'
- name: name
in: query
required: false
schema:
type: string
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: '#/components/schemas/Package'
components:
parameters:
currentUser:
name: current-user
in: header
required: true
schema:
type: string
description: Identifying name of the currently logged in user.
assumedRoles:
name: assumed-roles
in: header
required: false
schema:
type: string
description: Semicolon-separated list of roles to assume. The current user needs to have the right to assume these roles.
responses:
NotFound:
description: The specified was not found.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Unauthorized:
description: The current user is unknown or not authorized.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Forbidden:
description: The current user or none of the assumed or roles is granted access to the .
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
Customer:
type: object
properties:
uuid:
type: string
format: uuid
prefix:
type: string
reference:
type: integer
format: int32
adminUserName:
type: string
RbacUser:
type: object
properties:
uuid:
type: string
format: uuid
name:
type: string
RbacUserPermission:
type: object
properties:
objectUuid:
type: string
format: uuid
objectTable:
type: string
objectIdName:
type: string
roleName:
type: string
roleUuid:
type: string
format: uuid
permissionUuid:
type: string
format: uuid
op:
type: string
RbacRole:
type: object
properties:
uuid:
type: string
format: uuid
objectUuid:
type: string
format: uuid
objectTable:
type: string
objectIdName:
type: string
roleType:
type: string
enum:
- owner
- admin
- tenant
roleName:
type: string
Package:
type: object
properties:
uuid:
type: string
format: uuid
name:
type: string
customer:
$ref: '#/components/schemas/Customer'
Error:
type: object
properties:
code:
type: string
message:
type: string
required:
- code
- message

View File

@ -0,0 +1,14 @@
openapi-processor-mapping: v2
options:
package-name: net.hostsharing.hsadminng.generated.api.v1
model-name-suffix: Resource
map:
result: org.springframework.http.ResponseEntity
types:
- type: array => java.util.List
- type: string:uuid => java.util.UUID

View File

@ -36,7 +36,7 @@ class RbacRoleControllerRestTest {
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/rbacroles")
.get("/api/rbac-roles")
.header("current-user", "mike@hostsharing.net")
.accept(MediaType.APPLICATION_JSON))

View File

@ -12,6 +12,8 @@ import org.springframework.orm.jpa.JpaSystemException;
import javax.persistence.EntityManager;
import javax.transaction.Transactional;
import java.util.List;
import static net.hostsharing.test.JpaAttempt.attempt;
import static org.assertj.core.api.Assertions.assertThat;
@ -160,7 +162,7 @@ class RbacRoleRepositoryIntegrationTest {
assertThat(context.getAssumedRoles()).as("precondition").containsExactly(assumedRoles.split(";"));
}
void exactlyTheseRbacRolesAreReturned(final Iterable<RbacRoleEntity> actualResult, final String... expectedRoleNames) {
void exactlyTheseRbacRolesAreReturned(final List<RbacRoleEntity> actualResult, final String... expectedRoleNames) {
assertThat(actualResult)
.extracting(RbacRoleEntity::getRoleName)
.containsExactlyInAnyOrder(expectedRoleNames);

View File

@ -39,7 +39,7 @@ class RbacUserControllerRestTest {
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/rbacusers")
.get("/api/rbac-users")
.header("current-user", "mike@hostsharing.net")
.accept(MediaType.APPLICATION_JSON))
@ -59,7 +59,7 @@ class RbacUserControllerRestTest {
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/rbacusers")
.get("/api/rbac-users")
.param("name", "admin@aaa")
.header("current-user", "mike@hostsharing.net")
.accept(MediaType.APPLICATION_JSON))