-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Expose REST Api for creating an Account, add UseCase Implementation
- Loading branch information
Showing
17 changed files
with
718 additions
and
20 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
23
server/src/main/java/io/myfinbox/account/AccountCreated.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
113
server/src/main/java/io/myfinbox/account/CreateAccountService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
30
server/src/main/java/io/myfinbox/account/CreateAccountUseCase.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
|
||
} | ||
} |
38 changes: 38 additions & 0 deletions
38
server/src/main/java/io/myfinbox/account/web/AccountController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()))); | ||
} | ||
} |
38 changes: 38 additions & 0 deletions
38
server/src/main/java/io/myfinbox/account/web/AccountControllerApi.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
9
server/src/main/resources/db/migration/V1__account_schema.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
) | ||
; |
Oops, something went wrong.