Skip to content

Commit

Permalink
Expose REST API for create an expense category
Browse files Browse the repository at this point in the history
  • Loading branch information
semotpan committed Mar 23, 2024
1 parent 56e66e2 commit d220805
Show file tree
Hide file tree
Showing 11 changed files with 406 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.myfinbox.expense.adapter.web;

import io.myfinbox.expense.application.CategoryService;
import io.myfinbox.expense.domain.Category;
import io.myfinbox.shared.ApiFailureHandler;
import io.myfinbox.shared.ExpenseCategoryResource;
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 io.myfinbox.expense.application.CategoryService.CategoryCommand;
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 = "/expenses/categories")
@RequiredArgsConstructor
final class ExpenseCategoryController implements ExpenseCategoryControllerApi {

private final CategoryService categoryService;
private final ApiFailureHandler apiFailureHandler;

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> create(@RequestBody ExpenseCategoryResource resource) {
return categoryService.create(new CategoryCommand(resource.getName(), resource.getAccountId()))
.fold(apiFailureHandler::handle,
category -> created(fromCurrentRequest().path("/{id}").build(category.getId().id()))
.body(toResource(category)));
}

private ExpenseCategoryResource toResource(Category category) {
return new ExpenseCategoryResource()
.categoryId(category.getId().id())
.accountId(category.getAccount().id())
.name(category.getName())
.creationTimestamp(category.getCreationTimestamp().toString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.myfinbox.expense.adapter.web;

import io.myfinbox.shared.ApiErrorResponse;
import io.myfinbox.shared.ExpenseCategoryResource;
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 java.net.URI;

import static io.myfinbox.expense.adapter.web.ExpenseControllerApi.TAG;
import static org.springframework.http.HttpHeaders.LOCATION;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

public interface ExpenseCategoryControllerApi {

@Operation(summary = "Add a new expense category in the MyFinBox", description = "Add a new expense category in the MyFinBox",
security = {@SecurityRequirement(name = "openId")},
tags = {TAG})
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Successful Operation",
headers = @Header(name = LOCATION, description = "Created expense category URI location", schema = @Schema(implementation = URI.class)),
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ExpenseCategoryResource.class))),
@ApiResponse(responseCode = "400", description = "Malformed JSON or Type Mismatch Failure",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "409", description = "Category name already exists",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "422", description = "Request 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 = "Expense Category Resource to be created", required = true) ExpenseCategoryResource resource);

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import io.vavr.control.Either;

import java.util.List;
import java.util.UUID;

/**
* Service interface for managing categories.
Expand All @@ -20,4 +21,17 @@ public interface CategoryService {
*/
Either<Failure, List<Category>> createDefault(AccountIdentifier account);

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

record CategoryCommand(String name, UUID accountId) {

public static final String FIELD_NAME = "name";
public static final String FIELD_ACCOUNT_ID = "accountId";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,35 @@
import io.myfinbox.expense.domain.Category;
import io.myfinbox.expense.domain.DefaultCategories;
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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;

import static io.myfinbox.expense.application.CategoryService.CategoryCommand.FIELD_ACCOUNT_ID;
import static io.myfinbox.expense.application.CategoryService.CategoryCommand.FIELD_NAME;
import static io.vavr.API.Invalid;
import static io.vavr.API.Valid;
import static java.text.MessageFormat.format;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static org.apache.commons.lang3.StringUtils.isBlank;

@Service
@RequiredArgsConstructor
class DefaultCategoryService implements CategoryService {

public static final String VALIDATION_CREATE_FAILURE_MESSAGE = "The validation for the create category expense request has failed.";
public static final String CATEGORY_NAME_DUPLICATE_MESSAGE = "Category name already exists.";

private final CategoryCommandValidator validator = new CategoryCommandValidator();

private final Categories categories;

@Override
Expand All @@ -35,4 +51,57 @@ public Either<Failure, List<Category>> createDefault(AccountIdentifier account)

return Either.right(values);
}

@Override
@Transactional
public Either<Failure, Category> create(CategoryCommand command) {
var validation = validator.validate(command);
if (validation.isInvalid()) {
return Either.left(Failure.ofValidation(VALIDATION_CREATE_FAILURE_MESSAGE, validation.getError().toJavaList()));
}

if (categories.existsByNameAndAccount(command.name(), new AccountIdentifier(command.accountId()))) {
return Either.left(Failure.ofConflict(CATEGORY_NAME_DUPLICATE_MESSAGE));
}

var category = new Category(command.name(), new AccountIdentifier(command.accountId()));
categories.save(category);

return Either.right(category);
}

private static final class CategoryCommandValidator {

Validation<Seq<FieldViolation>, CategoryCommand> validate(CategoryCommand command) {
return Validation.combine(
validateAccountId(command.accountId()),
validateName(command.name())
).ap((accountId, categoryName) -> command);
}

private Validation<FieldViolation, UUID> validateAccountId(UUID accountId) {
if (nonNull(accountId))
return Valid(accountId);

return Invalid(FieldViolation.builder()
.field(FIELD_ACCOUNT_ID)
.message("AccountId cannot be null.")
.build());
}

private Validation<FieldViolation, String> validateName(String name) {
if (!isBlank(name) && name.length() <= Category.NAME_MAX_LENGTH)
return Valid(name);

var message = format("Name length cannot be more than {0}.", Category.NAME_MAX_LENGTH);
if (isBlank(name))
message = "Name cannot be empty.";

return Invalid(FieldViolation.builder()
.field(FIELD_NAME)
.message(message)
.rejectedValue(name)
.build());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public interface Categories extends JpaRepository<Category, CategoryIdentifier>

Optional<Category> findByIdAndAccount(CategoryIdentifier categoryId, AccountIdentifier accountId);

boolean existsByNameAndAccount(String name, AccountIdentifier accountId);

}
4 changes: 2 additions & 2 deletions server/src/main/java/io/myfinbox/expense/domain/Category.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
@NoArgsConstructor(access = PRIVATE, force = true)
public final class Category {

static final int MAX_LENGTH = 100;
public static final int NAME_MAX_LENGTH = 100;

@EmbeddedId
private final CategoryIdentifier id;
Expand All @@ -40,7 +40,7 @@ public Category(String name, AccountIdentifier account) {
this.id = new CategoryIdentifier(UUID.randomUUID());
this.account = notNull(account, "account cannot be null");
notBlank(name, "name cannot be blank");
this.name = doesNotOverflow(name, MAX_LENGTH, "name overflow, max length allowed '%d'".formatted(MAX_LENGTH));
this.name = doesNotOverflow(name, NAME_MAX_LENGTH, "name overflow, max length allowed '%d'".formatted(NAME_MAX_LENGTH));
this.creationTimestamp = Instant.now();
}

Expand Down
23 changes: 23 additions & 0 deletions server/src/main/resources/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,26 @@ components:
type: string
example: Course buying
description: Additional description attached to the expense.

ExpenseCategoryResource:
type: object
properties:
categoryId:
type: string
format: uuid
readOnly: true
description: Unique identifier for the expense category (read-only).
accountId:
type: string
format: uuid
example: 3b257779-a5db-4e87-9365-72c6f8d4977d
description: The ID of the account that submitted the expense category.
name:
type: string
example: Fun
description: The name of the category.
creationTimestamp:
type: string
readOnly: true
example: 2024-03-23T10:00:04.224870Z
description: The timestamp when the expense was created (read-only).
17 changes: 15 additions & 2 deletions server/src/test/groovy/io/myfinbox/expense/DataSamples.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.myfinbox.expense

import com.fasterxml.jackson.databind.json.JsonMapper
import groovy.json.JsonOutput
import io.myfinbox.expense.application.CategoryService
import io.myfinbox.expense.application.ExpenseCommand
import io.myfinbox.expense.domain.AccountIdentifier
import io.myfinbox.expense.domain.Category
Expand All @@ -14,7 +15,6 @@ class DataSamples {
.findAndAddModules()
.build()


static entityId = "3b257779-a5db-4e87-9365-72c6f8d4977d"
static accountId = "e2709aa2-7907-4f78-98b6-0f36a0c1b5ca"
static categoryId = "3b257779-a5db-4e87-9365-72c6f8d4977d"
Expand All @@ -32,7 +32,7 @@ class DataSamples {
id : [id: categoryId],
account : [id: accountId],
creationTimestamp: timestamp,
name : "Fun"
name : "Bills"
]

static EXPENSE = [
Expand Down Expand Up @@ -75,6 +75,11 @@ class DataSamples {
expenseDate: expenseDate,
]

static EXPENSE_CATEGORY_RESOURCE = [
accountId: accountId,
name : 'Bills',
]

static newSampleDefaultCategories(AccountIdentifier account) {
DefaultCategories.asList().stream()
.map { c -> new Category(c, account) }
Expand All @@ -100,4 +105,12 @@ class DataSamples {
static newSampleExpenseCreatedEvent(map = [:]) {
MAPPER.readValue(JsonOutput.toJson(EXPENSE_CREATED_EVENT + map) as String, ExpenseCreated.class)
}

static newSampleExpenseCategoryCommand(map = [:]) {
MAPPER.readValue(JsonOutput.toJson(EXPENSE_CATEGORY_RESOURCE + map) as String, CategoryService.CategoryCommand.class)
}

static newValidExpenseCategoryResource(map = [:]) {
JsonOutput.toJson(EXPENSE_CATEGORY_RESOURCE + map) as String
}
}
Loading

0 comments on commit d220805

Please sign in to comment.