Skip to content

Commit

Permalink
Expose Account query API
Browse files Browse the repository at this point in the history
  • Loading branch information
semotpan committed Aug 6, 2024
1 parent 48b8d96 commit c6d5666
Show file tree
Hide file tree
Showing 13 changed files with 201 additions and 38 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.myfinbox.account.adapter.persistence;

import io.myfinbox.account.application.AccountQuery;
import io.myfinbox.account.domain.Account;
import io.myfinbox.account.domain.Accounts;
import io.myfinbox.shared.Failure;
import io.vavr.control.Either;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;

import static io.myfinbox.account.domain.Account.AccountIdentifier;
import static io.myfinbox.shared.Failure.ofNotFound;
import static java.util.Objects.isNull;

@Repository
@RequiredArgsConstructor
class JpaAccountQuery implements AccountQuery {

static final String ACCOUNT_NOT_FOUND = "Account '%s' not found.";

private final Accounts accounts;

@Override
@Transactional(readOnly = true)
public Either<Failure, Account> findOne(UUID accountId) {
if (isNull(accountId)) {
return Either.left(ofNotFound(ACCOUNT_NOT_FOUND.formatted(accountId)));
}

return accounts.findById(new AccountIdentifier(accountId))
.<Either<Failure, Account>>map(Either::right)
.orElseGet(() -> Either.left(ofNotFound(ACCOUNT_NOT_FOUND.formatted(accountId))));
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package io.myfinbox.account.adapter.web;

import io.myfinbox.account.application.AccountQuery;
import io.myfinbox.account.application.CreateAccountUseCase;
import io.myfinbox.account.application.CreateAccountUseCase.CreateAccountCommand;
import io.myfinbox.rest.AccountCreateResource;
import io.myfinbox.account.domain.Account;
import io.myfinbox.rest.AccountResource;
import io.myfinbox.shared.ApiFailureHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Currency;
import java.util.Locale;
import java.util.UUID;

import static java.util.Objects.isNull;
import static org.springframework.http.HttpHeaders.ACCEPT_LANGUAGE;
Expand All @@ -25,11 +28,12 @@ final class AccountController implements AccountsApi {
static final Locale defaultLocale = Locale.of("en", "MD");

private final CreateAccountUseCase createAccountUseCase;
private final AccountQuery accountQuery;
private final ApiFailureHandler apiFailureHandler;

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> create(@RequestHeader(name = ACCEPT_LANGUAGE, required = false) Locale locale,
@RequestBody AccountCreateResource resource) {
@RequestBody AccountResource resource) {
var command = CreateAccountCommand.builder()
.firstName(resource.getFirstName())
.lastName(resource.getLastName())
Expand All @@ -46,6 +50,12 @@ public ResponseEntity<?> create(@RequestHeader(name = ACCEPT_LANGUAGE, required
));
}

@GetMapping(path = "/{accountId}", produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> one(@PathVariable UUID accountId) {
return accountQuery.findOne(accountId)
.fold(apiFailureHandler::handle, account -> ResponseEntity.ok(toResource(account)));
}

private Locale resolve(Locale locale) {
try {
if (isNull(Currency.getInstance(locale).getCurrencyCode())) {
Expand All @@ -57,4 +67,15 @@ private Locale resolve(Locale locale) {

return locale;
}

private AccountResource toResource(Account account) {
return new AccountResource()
.accountId(account.getId().id())
.creationTimestamp(account.getCreationTimestamp().toString())
.firstName(account.getAccountDetails().firstName())
.lastName(account.getAccountDetails().lastName())
.emailAddress(account.getEmailAddress().value())
.zoneId(account.getPreference().zoneId().getId())
.currency(account.getPreference().currency().getCurrencyCode());
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package io.myfinbox.account.adapter.web;

import io.myfinbox.rest.AccountCreateResource;
import io.myfinbox.rest.AccountResource;
import io.myfinbox.shared.ApiErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand All @@ -15,8 +15,10 @@

import java.net.URI;
import java.util.Locale;
import java.util.UUID;

import static io.swagger.v3.oas.annotations.enums.ParameterIn.HEADER;
import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH;
import static org.springframework.http.HttpHeaders.LOCATION;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

Expand All @@ -30,7 +32,7 @@ public interface AccountsApi {
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Successful Operation",
headers = @Header(name = LOCATION, description = "Created account URI location", schema = @Schema(implementation = URI.class)),
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = AccountCreateResource.class))),
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = AccountResource.class))),
@ApiResponse(responseCode = "400", description = "Malformed or Type Mismatch Failure",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "409", description = "Email Address already exists",
Expand All @@ -41,6 +43,21 @@ public interface AccountsApi {
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)))
})
ResponseEntity<?> create(@Parameter(in = HEADER) Locale locale,
@RequestBody(description = "AccountResource to be created", required = true) AccountCreateResource resource);
@RequestBody(description = "AccountResource to be created", required = true) AccountResource resource);

@Operation(summary = "Get one account in the MyFinBox", description = "Get one account in the MyFinBox",
security = {@SecurityRequirement(name = "openId")},
tags = {TAGS})
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Get one existing account",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = AccountResource.class))),
@ApiResponse(responseCode = "400", description = "Malformed or Type Mismatch Failure",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Account not found",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "500", description = "Internal Server Error",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)))
})
ResponseEntity<?> one(@Parameter(in = PATH, description = "Account ID to be found") UUID accountId);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.myfinbox.account.application;

import io.myfinbox.account.domain.Account;
import io.myfinbox.shared.Failure;
import io.vavr.control.Either;

import java.util.UUID;

/**
* Interface for querying account information.
*/
public interface AccountQuery {

/**
* Finds an account by its unique identifier.
*
* @param accountId the unique identifier of the account to find
* @return an {@link Either} instance containing either a {@link Failure} or the {@link Account} found
*/
Either<Failure, Account> findOne(UUID accountId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,15 @@ private Validation<FieldViolation, String> validateEmailAddress(String emailAddr
.build());
}

if (Account.MAX_LENGTH < emailAddress.length()) {
if (EmailAddress.MAX_LENGTH < emailAddress.length()) {
return Invalid(FieldViolation.builder()
.field(FIELD_EMAIL_ADDRESS)
.message("Email address length cannot exceed '%d' characters.".formatted(Account.MAX_LENGTH))
.rejectedValue(emailAddress)
.build());
}

if (!Pattern.compile(Account.patternRFC5322).matcher(emailAddress).matches()) {
if (!Pattern.compile(EmailAddress.patternRFC5322).matcher(emailAddress).matches()) {
return Invalid(FieldViolation.builder()
.field(FIELD_EMAIL_ADDRESS)
.message("Email address must follow RFC 5322 standard.")
Expand Down
11 changes: 4 additions & 7 deletions server/src/main/java/io/myfinbox/account/domain/Account.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import java.io.Serializable;
import java.time.Instant;
import java.util.UUID;
import java.util.regex.Pattern;

import static io.myfinbox.shared.Guards.notNull;
import static lombok.AccessLevel.PRIVATE;
Expand All @@ -23,14 +22,12 @@ public class Account extends AbstractAggregateRoot<Account> {

public static final int MAX_LENGTH = 255;

public static final String patternRFC5322 = "^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$";
static final Pattern pattern = Pattern.compile(patternRFC5322);

@EmbeddedId
private final AccountIdentifier id;
private final Instant creationDate;
private final Instant creationTimestamp;

@Embedded
@AttributeOverride(name = "value", column = @Column(name = "email_address"))
private EmailAddress emailAddress;

@Embedded
Expand All @@ -48,11 +45,11 @@ public Account(AccountDetails accountDetails,
this.preference = notNull(preference, "preference cannot be null");

this.id = new AccountIdentifier(UUID.randomUUID());
this.creationDate = Instant.now();
this.creationTimestamp = Instant.now();

registerEvent(AccountCreated.builder()
.accountId(this.id.id())
.emailAddress(this.emailAddress.emailAddress())
.emailAddress(this.emailAddress.value())
.firstName(this.accountDetails.firstName())
.lastName(this.accountDetails.lastName())
.currency(this.preference.currency())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@
import static io.myfinbox.shared.Guards.*;

@Embeddable
public record EmailAddress(String emailAddress) implements Serializable {
public record EmailAddress(String value) implements Serializable {

static final String patternRFC5322 = "^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$";
public static final String patternRFC5322 = "^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$";
static final Pattern pattern = Pattern.compile(patternRFC5322);
static final int MAX_LENGTH = 255;
public static final int MAX_LENGTH = 255;

public EmailAddress {
notBlank(emailAddress, "emailAddress cannot be blank");
doesNotOverflow(emailAddress.trim(), MAX_LENGTH, "emailAddress max length must be '%d'".formatted(MAX_LENGTH));
matches(emailAddress, pattern, "emailAddress must match '%s'".formatted(patternRFC5322));
notBlank(value, "value cannot be blank");
doesNotOverflow(value.trim(), MAX_LENGTH, "value max length must be '%d'".formatted(MAX_LENGTH));
matches(value, pattern, "value must match '%s'".formatted(patternRFC5322));
}

@Override
public String toString() {
return emailAddress;
return value;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
CREATE TABLE IF NOT EXISTS accounts
(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
email_address VARCHAR(256) NOT NULL UNIQUE,
first_name VARCHAR(255),
last_name VARCHAR(255)
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
creation_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
email_address VARCHAR(256) NOT NULL UNIQUE,
first_name VARCHAR(255),
last_name VARCHAR(255)
)
;
7 changes: 6 additions & 1 deletion server/src/main/resources/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ paths:
components:
schemas:
# Request Objects
AccountCreateResource:
AccountResource:
type: object
properties:
accountId:
Expand All @@ -37,6 +37,11 @@ components:
type: string
example: Doe
description: The last name of the account holder.
creationTimestamp:
type: string
readOnly: true
example: 2024-03-23T10:00:04.224870Z
description: The timestamp when the expense was created (read-only).
zoneId:
type: string
example: Europe/Paris
Expand Down
14 changes: 12 additions & 2 deletions server/src/test/groovy/io/myfinbox/account/DataSamples.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ class DataSamples {
.findAndAddModules()
.build()

static CREATE_ACCOUNT_RESOURCE = [
static ACCOUNT_RESOURCE = [
accountId : "e2709aa2-7907-4f78-98b6-0f36a0c1b5ca",
firstName : "Jon",
lastName : "Snow",
emailAddress : "jonsnow@gmail.com",
zoneId : "Europe/Chisinau",
currency : "MDL",
creationTimestamp: "2024-03-23T10:00:04.224870Z"
]

static ACCOUNT_CREATE_COMMAND = [
firstName : "Jon",
lastName : "Snow",
emailAddress: "jonsnow@gmail.com",
Expand All @@ -29,7 +39,7 @@ class DataSamples {
]

static newSampleCreateAccountCommand(map = [:]) {
MAPPER.readValue(JsonOutput.toJson(CREATE_ACCOUNT_RESOURCE + map) as String, CreateAccountCommand.class)
MAPPER.readValue(JsonOutput.toJson(ACCOUNT_CREATE_COMMAND + map) as String, CreateAccountCommand.class)
}

static newSampleAccountEvent(map = [:]) {
Expand Down
Loading

0 comments on commit c6d5666

Please sign in to comment.