Skip to content

Commit

Permalink
feat: add rest apis (#8)
Browse files Browse the repository at this point in the history
* add account and plan rest apis

* add subscription rest apis
  • Loading branch information
gabrmsouza authored Aug 29, 2024
1 parent 328fe81 commit ed21746
Show file tree
Hide file tree
Showing 68 changed files with 2,109 additions and 123 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public static Notification create(final Throwable anError) {
return create(Error.with(anError.getMessage()));
}

public static Notification create(final List<Error> errors) {
return new Notification(new ArrayList<>(errors));
}

@Override
public Notification append(final Error anError) {
errors.add(anError);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package io.github.gabrmsouza.subscription.domain;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

@Tag("unitTest")
@ExtendWith(MockitoExtension.class)
public class UnitTest {
}
1 change: 1 addition & 0 deletions infrastructure/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies {
implementation('org.springframework.boot:spring-boot-starter-undertow')
implementation('org.springframework.boot:spring-boot-starter-security')
implementation('org.springframework.boot:spring-boot-starter-oauth2-resource-server')
implementation("org.springframework.boot:spring-boot-starter-validation")

implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdoc") {
exclude group: 'org.springdoc', module: 'springdoc-openapi-ui'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication;
package io.github.gabrmsouza.subscription.infrastructure.authentication.clientcredentials;

public interface AuthenticationGateway {
AuthenticationResult login(ClientCredentialsInput input);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication;
package io.github.gabrmsouza.subscription.infrastructure.authentication.clientcredentials;

import io.github.gabrmsouza.subscription.infrastructure.authentication.AuthenticationGateway.ClientCredentialsInput;
import io.github.gabrmsouza.subscription.infrastructure.authentication.AuthenticationGateway.RefreshTokenInput;
import io.github.gabrmsouza.subscription.infrastructure.authentication.clientcredentials.AuthenticationGateway.ClientCredentialsInput;
import io.github.gabrmsouza.subscription.infrastructure.authentication.clientcredentials.AuthenticationGateway.RefreshTokenInput;
import io.github.gabrmsouza.subscription.infrastructure.configuration.properties.KeycloakProperties;
import org.springframework.stereotype.Component;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication;
package io.github.gabrmsouza.subscription.infrastructure.authentication.clientcredentials;

public interface GetClientCredentials {
String retrieve();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication;
package io.github.gabrmsouza.subscription.infrastructure.authentication.clientcredentials;

import io.github.gabrmsouza.subscription.domain.exceptions.InternalErrorException;
import io.github.gabrmsouza.subscription.infrastructure.configuration.annontations.Keycloak;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication;
package io.github.gabrmsouza.subscription.infrastructure.authentication.clientcredentials;

public interface RefreshClientCredentials {
void refresh();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication.principal;

import io.github.gabrmsouza.subscription.domain.account.Account;
import io.github.gabrmsouza.subscription.domain.account.idp.UserId;

import java.util.Optional;
import java.util.function.Function;

public interface AccountFromUserIdResolver extends Function<UserId, Optional<Account>> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication.principal;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;

import java.util.Collection;


public class CodeflixAuthentication extends AbstractAuthenticationToken {

private final Jwt jwt;
private final CodeflixUser user;

/**
* Creates a token with the supplied array of authorities.
*
* @param authorities the collection of <tt>GrantedAuthority</tt>s for the principal
* represented by this authentication object.
*/
public CodeflixAuthentication(Jwt jwt, CodeflixUser user, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.jwt = jwt;
this.user = user;
}

@Override
public Object getCredentials() {
return jwt;
}

@Override
public Object getPrincipal() {
return user;
}

@Override
public boolean isAuthenticated() {
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication.principal;

import io.github.gabrmsouza.subscription.domain.account.idp.UserId;
import io.github.gabrmsouza.subscription.infrastructure.exceptions.ForbiddenException;
import org.springframework.security.oauth2.jwt.Jwt;

import java.util.Optional;

public record CodeflixUser(
String name,
String idpUserId,
String accountId
) implements User {

private static final String ACCOUNT_ID = "account_id";
private static final String NAME = "name";

public static CodeflixUser fromJwt(Jwt jwt, AccountFromUserIdResolver accountResolver) {
final var idpUserId = jwt.getSubject();
return new CodeflixUser(
jwt.getClaimAsString(NAME),
idpUserId,
Optional.ofNullable(jwt.getClaimAsString(ACCOUNT_ID))
.or(() -> accountResolver.apply(new UserId(idpUserId)).map(acc -> acc.userId().value()))
.orElseThrow(() -> ForbiddenException.with("Could not resolve account from user"))
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication.principal;

import io.github.gabrmsouza.subscription.domain.account.AccountGateway;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Component
public class KeycloakJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {

private final AccountGateway accountGateway;
private final KeycloakAuthoritiesConverter authoritiesConverter;

public KeycloakJwtConverter(final AccountGateway accountGateway) {
this.accountGateway = Objects.requireNonNull(accountGateway);
this.authoritiesConverter = new KeycloakAuthoritiesConverter();
}

@Override
public AbstractAuthenticationToken convert(final Jwt jwt) {
return new CodeflixAuthentication(jwt, extractPrincipal(jwt), extractAuthorities(jwt));
}

private CodeflixUser extractPrincipal(final Jwt jwt) {
return CodeflixUser.fromJwt(jwt, accountGateway::accountOfUserId);
}

private Collection<? extends GrantedAuthority> extractAuthorities(final Jwt jwt) {
return this.authoritiesConverter.convert(jwt);
}

static class KeycloakAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

private static final String REALM_ACCESS = "realm_access";
private static final String ROLES = "roles";
private static final String RESOURCE_ACCESS = "resource_access";
private static final String SEPARATOR = "_";
private static final String ROLE_PREFIX = "ROLE_";

@Override
public Collection<GrantedAuthority> convert(final Jwt jwt) {
final var realmRoles = extractRealmRoles(jwt);
final var resourceRoles = extractResourceRoles(jwt);

return Stream.concat(realmRoles, resourceRoles)
.map(role -> new SimpleGrantedAuthority(ROLE_PREFIX + role.toUpperCase()))
.collect(Collectors.toSet());
}

private Stream<String> extractResourceRoles(final Jwt jwt) {

final Function<Map.Entry<String, Object>, Stream<String>> mapResource =
resource -> {
final var key = resource.getKey();
final var value = (Map) resource.getValue();
final var roles = (Collection<String>) value.get(ROLES);
return roles.stream().map(role -> key.concat(SEPARATOR).concat(role));
};

final Function<Set<Map.Entry<String, Object>>, Collection<String>> mapResources =
resources -> resources.stream()
.flatMap(mapResource)
.toList();

return Optional.ofNullable(jwt.getClaimAsMap(RESOURCE_ACCESS))
.map(Map::entrySet)
.map(mapResources)
.orElse(Collections.emptyList())
.stream();
}

private Stream<String> extractRealmRoles(final Jwt jwt) {
return Optional.ofNullable(jwt.getClaimAsMap(REALM_ACCESS))
.map(resource -> (Collection<String>) resource.get(ROLES))
.orElse(Collections.emptyList())
.stream();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.github.gabrmsouza.subscription.infrastructure.authentication.principal;

public interface User {
String name();
String idpUserId();
String accountId();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.github.gabrmsouza.subscription.infrastructure.configuration;

import io.github.gabrmsouza.subscription.domain.exceptions.DomainException;
import io.github.gabrmsouza.subscription.domain.exceptions.InternalErrorException;
import io.github.gabrmsouza.subscription.domain.validation.Error;
import io.github.gabrmsouza.subscription.domain.validation.handler.Notification;
import io.github.gabrmsouza.subscription.infrastructure.exceptions.ForbiddenException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return ResponseEntity.unprocessableEntity()
.body(Notification.create(covertError(ex.getBindingResult().getAllErrors())));
}

@ExceptionHandler(DomainException.class)
public ResponseEntity<?> handleDomainException(DomainException ex) {
return ResponseEntity.unprocessableEntity().body(ex.getErrors());
}

@ExceptionHandler(InternalErrorException.class)
public ResponseEntity<?> handleInternalErrorException(InternalErrorException ex) {
return ResponseEntity.internalServerError().body(new Error("", ex.getMessage()));
}

@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<?> handleForbiddenException(ForbiddenException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new Error("Authentication", ex.getMessage()));
}

private List<Error> covertError(List<ObjectError> allErrors) {
return allErrors.stream()
.map(e -> new Error(((FieldError) e).getField(), e.getDefaultMessage()))
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.gabrmsouza.subscription.infrastructure.configuration.annontations.Keycloak;
import io.github.gabrmsouza.subscription.infrastructure.configuration.annontations.KeycloakAdmin;
import io.github.gabrmsouza.subscription.infrastructure.configuration.properties.RestClientProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
Expand Down Expand Up @@ -29,6 +30,19 @@ RestClient keycloakHttpClient(@Keycloak final RestClientProperties properties, f
return restClient(properties, mapper);
}

@Bean
@KeycloakAdmin
@ConfigurationProperties(prefix = "rest-client.keycloak-admin")
public RestClientProperties keycloakAdminRestClientProperties() {
return new RestClientProperties();
}

@Bean
@KeycloakAdmin
public RestClient keycloakAdminHttpClient(@KeycloakAdmin final RestClientProperties properties, final ObjectMapper objectMapper) {
return restClient(properties, objectMapper);
}

private RestClient restClient(final RestClientProperties properties, final ObjectMapper mapper) {
final var factory = new JdkClientHttpRequestFactory();
factory.setReadTimeout(properties.readTimeout());
Expand Down
Loading

0 comments on commit ed21746

Please sign in to comment.