Skip to content

Commit

Permalink
Expose REST Api for creating an Account, add UseCase Implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
semotpan committed Mar 17, 2024
1 parent 7e250df commit b55f258
Show file tree
Hide file tree
Showing 17 changed files with 718 additions and 20 deletions.
11 changes: 8 additions & 3 deletions server/src/main/java/io/myfinbox/ServerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.modulith.Modulithic;

@SpringBootApplication
@Modulithic(
sharedModules = {
"io.myfinbox.shared"
}, useFullyQualifiedModuleNames = true)
public class ServerApplication {

public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}

}
92 changes: 92 additions & 0 deletions server/src/main/java/io/myfinbox/account/Account.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package io.myfinbox.account;

import jakarta.persistence.*;
import lombok.*;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.AbstractAggregateRoot;

import java.io.Serializable;
import java.time.Instant;
import java.util.UUID;

import static java.util.Objects.requireNonNull;
import static java.util.regex.Pattern.compile;
import static lombok.AccessLevel.PRIVATE;
import static org.apache.commons.lang3.StringUtils.isBlank;

@Entity
@Table(name = "accounts")
@Getter
@ToString
@EqualsAndHashCode(callSuper = false)
@NoArgsConstructor(access = PRIVATE, force = true)
public class Account extends AbstractAggregateRoot<Account> {

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

private @EmbeddedId AccountIdentifier id;
private @Embedded EmailAddress emailAddress;
private String firstName;
private String lastName;
private Instant creationDate;

@Builder
public Account(String firstName, String lastName, EmailAddress emailAddress) {
this.emailAddress = requireNonNull(emailAddress, "emailAddress cannot be null");

if (!StringUtils.isBlank(firstName)) {
requireNonOverflow(firstName, "firstName overflow, max length allowed '%d'".formatted(MAX_LENGTH));
this.firstName = firstName.trim();
}

if (!StringUtils.isBlank(lastName)) {
requireNonOverflow(lastName, "lastName overflow, max length allowed '%d'".formatted(MAX_LENGTH));
this.lastName = lastName.trim();
}

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

registerEvent(new AccountCreated(this.id, this.emailAddress, firstName, lastName));
}

private static void requireNonOverflow(String text, String message) {
if (StringUtils.length(text) > Account.MAX_LENGTH)
throw new IllegalArgumentException(message);
}

@Embeddable
public record AccountIdentifier(UUID id) implements Serializable {

public AccountIdentifier {
requireNonNull(id, "id cannot be null");
}

@Override
public String toString() {
return id.toString();
}
}

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

public EmailAddress {
if (isBlank(emailAddress)) {
throw new IllegalArgumentException("emailAddress cannot be blank");
}

requireNonOverflow(emailAddress, "emailAddress max length must be '%d'".formatted(MAX_LENGTH));

if (!compile(patternRFC5322).matcher(emailAddress).matches()) {
throw new IllegalArgumentException("emailAddress must match '%s'".formatted(patternRFC5322));
}
}

@Override
public String toString() {
return emailAddress;
}
}
}
23 changes: 23 additions & 0 deletions server/src/main/java/io/myfinbox/account/AccountCreated.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package io.myfinbox.account;

import io.myfinbox.account.Account.AccountIdentifier;
import io.myfinbox.account.Account.EmailAddress;
import io.myfinbox.shared.DomainEvent;
import lombok.Builder;

import static java.util.Objects.requireNonNull;

/**
* Represents an event indicating that an account has been created.
*/
@Builder
public record AccountCreated(AccountIdentifier accountIdentifier,
EmailAddress emailAddress,
String firstName,
String lastName) implements DomainEvent {

public AccountCreated {
requireNonNull(accountIdentifier, "accountIdentifier cannot be null");
requireNonNull(emailAddress, "emailAddress cannot be null");
}
}
16 changes: 16 additions & 0 deletions server/src/main/java/io/myfinbox/account/Accounts.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.myfinbox.account;

import io.myfinbox.account.Account.AccountIdentifier;
import io.myfinbox.account.Account.EmailAddress;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

/**
* Repository interface for managing accounts.
*/
@Repository
public interface Accounts extends CrudRepository<Account, AccountIdentifier> {

boolean existsByEmailAddress(EmailAddress emailAddress);

}
113 changes: 113 additions & 0 deletions server/src/main/java/io/myfinbox/account/CreateAccountService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package io.myfinbox.account;

import io.myfinbox.shared.Failure;
import io.myfinbox.shared.Failure.FieldViolation;
import io.vavr.collection.Seq;
import io.vavr.control.Either;
import io.vavr.control.Validation;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.regex.Pattern;

import static io.myfinbox.account.CreateAccountUseCase.CreateAccountCommand.*;
import static io.vavr.API.Invalid;
import static io.vavr.API.Valid;

@Service
@Transactional
@RequiredArgsConstructor
class CreateAccountService implements CreateAccountUseCase {

static final String ERROR_MESSAGE = "validation failed on create account request.";

private final CommandValidator validator = new CommandValidator();
private final Accounts accounts;

@Override
public Either<Failure, Account> create(CreateAccountCommand cmd) {
var validation = validator.validate(cmd);
if (validation.isInvalid()) {
return Either.left(Failure.ofValidation(ERROR_MESSAGE, validation.getError().toJavaList()));
}

if (accounts.existsByEmailAddress(new Account.EmailAddress(cmd.emailAddress()))) {
return Either.left(Failure.ofConflict("email address '%s' already exists.".formatted(cmd.emailAddress())));
}

var account = Account.builder()
.firstName(cmd.firstName())
.lastName(cmd.lastName())
.emailAddress(new Account.EmailAddress(cmd.emailAddress()))
.build();

accounts.save(account);

return Either.right(account);
}

private static final class CommandValidator {
Validation<Seq<FieldViolation>, CreateAccountCommand> validate(CreateAccountCommand cmd) {
return Validation.combine(
validateFirstName(cmd.firstName()),
validateLastName(cmd.lastName()),
validateEmailAddress(cmd.emailAddress())
).ap((firstName, lastName, emailAddress) -> cmd);
}

private Validation<FieldViolation, String> validateFirstName(String firstName) {
if (StringUtils.isBlank(firstName) || firstName.length() <= Account.MAX_LENGTH) {
return Valid(firstName);
}

return Invalid(FieldViolation.builder()
.field(FIELD_FIRST_NAME)
.message("first name length cannot be more than '%d'.".formatted(Account.MAX_LENGTH))
.rejectedValue(firstName)
.build());
}

private Validation<FieldViolation, String> validateLastName(String lastName) {
if (StringUtils.isBlank(lastName) || lastName.length() <= Account.MAX_LENGTH) {
return Valid(lastName);
}


return Invalid(FieldViolation.builder()
.field(FIELD_LAST_NAME)
.message("last name length cannot be more than '%d'.".formatted(Account.MAX_LENGTH))
.rejectedValue(lastName)
.build());
}

private Validation<FieldViolation, String> validateEmailAddress(String emailAddress) {
if (StringUtils.isBlank(emailAddress)) {
return Invalid(FieldViolation.builder()
.field(FIELD_EMAIL_ADDRESS)
.message("email address cannot be empty.")
.rejectedValue(emailAddress)
.build());
}

if (Account.MAX_LENGTH < emailAddress.length()) {
return Invalid(FieldViolation.builder()
.field(FIELD_EMAIL_ADDRESS)
.message("email address length cannot be more than '%s'.".formatted(Account.MAX_LENGTH))
.rejectedValue(emailAddress)
.build());
}

if (!Pattern.compile(Account.patternRFC5322).matcher(emailAddress).matches()) {
return Invalid(FieldViolation.builder()
.field(FIELD_EMAIL_ADDRESS)
.message("email address must follow RFC 5322 standard.")
.rejectedValue(emailAddress)
.build());
}

return Valid(emailAddress);
}
}
}
30 changes: 30 additions & 0 deletions server/src/main/java/io/myfinbox/account/CreateAccountUseCase.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.myfinbox.account;

import io.myfinbox.shared.Failure;
import io.vavr.control.Either;
import lombok.Builder;

/**
* This interface defines the contract for creating an account.
*/
public interface CreateAccountUseCase {

/**
* Creates an account based on the provided command.
*
* @param cmd The command containing account creation details.
* @return {@link Either} a {@link Failure} instance if the account creation fails, or the created {@link Account} instance.
*/
Either<Failure, Account> create(CreateAccountCommand cmd);

@Builder
record CreateAccountCommand(String firstName,
String lastName,
String emailAddress) {

public static final String FIELD_FIRST_NAME = "firstName";
public static final String FIELD_LAST_NAME = "lastName";
public static final String FIELD_EMAIL_ADDRESS = "emailAddress";

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

import io.myfinbox.account.CreateAccountUseCase;
import io.myfinbox.account.CreateAccountUseCase.CreateAccountCommand;
import io.myfinbox.shared.AccountCreateResource;
import io.myfinbox.shared.ApiFailureHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.http.ResponseEntity.created;
import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest;

@RestController
@RequestMapping(path = "/accounts")
@RequiredArgsConstructor
final class AccountController implements AccountControllerApi {

private final CreateAccountUseCase createAccountUseCase;
private final ApiFailureHandler apiFailureHandler;

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> create(@RequestBody AccountCreateResource resource) {
var command = CreateAccountCommand.builder()
.firstName(resource.getFirstName())
.lastName(resource.getLastName())
.emailAddress(resource.getEmailAddress())
.build();

return createAccountUseCase.create(command).fold(apiFailureHandler::handle,
account -> created(fromCurrentRequest().path("/{id}").build(account.getId().toString()))
.body(resource.accountId(account.getId().id())));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.myfinbox.account.web;

import io.myfinbox.shared.AccountCreateResource;
import io.myfinbox.shared.ApiErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springframework.http.ResponseEntity;

import static org.springframework.http.HttpHeaders.LOCATION;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

public interface AccountControllerApi {

String TAGS = "accounts";

@Operation(summary = "Create a new account in the myfinbox", description = "Create a new account in the myfinbox",
security = {@SecurityRequirement(name = "openId")},
tags = {TAGS})
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Successful Operation", headers = @Header(name = LOCATION), content = @Content),
@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",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "422", description = "Schema Validation Failure",
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<?> create(@RequestBody(description = "AccountResource to be created", required = true) AccountCreateResource resource);

}
10 changes: 10 additions & 0 deletions server/src/main/java/io/myfinbox/shared/DomainEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.myfinbox.shared;

import java.time.Instant;

public interface DomainEvent {

default Instant issuedOn() {
return Instant.now();
}
}
9 changes: 9 additions & 0 deletions server/src/main/resources/db/migration/V1__account_schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +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)
)
;
Loading

0 comments on commit b55f258

Please sign in to comment.