Skip to content

Commit

Permalink
Expose REST API for update an expense category
Browse files Browse the repository at this point in the history
  • Loading branch information
semotpan committed Mar 24, 2024
1 parent d220805 commit 9b474c5
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
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 org.springframework.web.bind.annotation.*;

import java.util.UUID;

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.http.ResponseEntity.ok;
import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest;

@RestController
@RequestMapping(path = "/expenses/categories")
@RequestMapping(path = "/expenses/category")
@RequiredArgsConstructor
final class ExpenseCategoryController implements ExpenseCategoryControllerApi {

Expand All @@ -32,6 +32,12 @@ public ResponseEntity<?> create(@RequestBody ExpenseCategoryResource resource) {
.body(toResource(category)));
}

@PutMapping(path = "/{categoryId}", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> update(@PathVariable UUID categoryId, @RequestBody ExpenseCategoryResource resource) {
return categoryService.update(categoryId, new CategoryCommand(resource.getName(), resource.getAccountId()))
.fold(apiFailureHandler::handle, category -> ok(toResource(category)));
}

private ExpenseCategoryResource toResource(Category category) {
return new ExpenseCategoryResource()
.categoryId(category.getId().id())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.myfinbox.shared.ApiErrorResponse;
import io.myfinbox.shared.ExpenseCategoryResource;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
Expand All @@ -13,6 +14,7 @@
import org.springframework.http.ResponseEntity;

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

import static io.myfinbox.expense.adapter.web.ExpenseControllerApi.TAG;
import static org.springframework.http.HttpHeaders.LOCATION;
Expand All @@ -38,4 +40,23 @@ public interface ExpenseCategoryControllerApi {
})
ResponseEntity<?> create(@RequestBody(description = "Expense Category Resource to be created", required = true) ExpenseCategoryResource resource);

@Operation(summary = "Update an expense category name in the MyFinBox", description = "Update an expense category name in the MyFinBox",
security = {@SecurityRequirement(name = "openId")},
tags = {TAG})
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successful Operation",
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 = "404", description = "Category not found",
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<?> update(@Parameter(description = "CategoryId to be updated", required = true) UUID categoryId,
@RequestBody(description = "Name to be updated", required = true) ExpenseCategoryResource resource);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
/**
* Service interface for managing categories.
*/
// FIXME: ensure the current auth user matches the accountID
public interface CategoryService {

/**
Expand All @@ -29,6 +30,14 @@ public interface CategoryService {
*/
Either<Failure, Category> create(CategoryCommand command);

/**
* Updates a category name based on the provided command.
*
* @param command The command containing category update details.
* @return {@link Either} a {@link Failure} instance if the category update fails, or the update {@link Category} instance.
*/
Either<Failure, Category> update(UUID categoryId, CategoryCommand command);

record CategoryCommand(String name, UUID accountId) {

public static final String FIELD_NAME = "name";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.myfinbox.expense.domain.AccountIdentifier;
import io.myfinbox.expense.domain.Categories;
import io.myfinbox.expense.domain.Category;
import io.myfinbox.expense.domain.Category.CategoryIdentifier;
import io.myfinbox.expense.domain.DefaultCategories;
import io.myfinbox.shared.Failure;
import io.myfinbox.shared.Failure.FieldViolation;
Expand Down Expand Up @@ -30,7 +31,9 @@
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 VALIDATION_UPDATE_FAILURE_MESSAGE = "The validation for the update category expense request has failed.";
public static final String CATEGORY_NAME_DUPLICATE_MESSAGE = "Category name already exists.";
public static final String CATEGORY_NOT_FOUND_MESSAGE = "Category not found.";

private final CategoryCommandValidator validator = new CategoryCommandValidator();

Expand Down Expand Up @@ -70,6 +73,37 @@ public Either<Failure, Category> create(CategoryCommand command) {
return Either.right(category);
}

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

if (isNull(categoryId)) {
return Either.left(Failure.ofNotFound(CATEGORY_NOT_FOUND_MESSAGE));
}

var category = categories.findByIdAndAccount(new CategoryIdentifier(categoryId), new AccountIdentifier(command.accountId()));
if (category.isEmpty()) {
return Either.left(Failure.ofNotFound(CATEGORY_NOT_FOUND_MESSAGE));
}

if (category.get().sameName(command.name())) {
return Either.right(category.get());
}

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

category.get().setName(command.name());
categories.save(category.get()); // FIXME: fix the save anti-pattern

return Either.right(category.get());
}

private static final class CategoryCommandValidator {

Validation<Seq<FieldViolation>, CategoryCommand> validate(CategoryCommand command) {
Expand Down
10 changes: 9 additions & 1 deletion server/src/main/java/io/myfinbox/expense/domain/Category.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ public final class Category {
public Category(String name, AccountIdentifier account) {
this.id = new CategoryIdentifier(UUID.randomUUID());
this.account = notNull(account, "account cannot be null");
setName(name);
this.creationTimestamp = Instant.now();
}

public void setName(String name) {
notBlank(name, "name cannot be blank");
this.name = doesNotOverflow(name, NAME_MAX_LENGTH, "name overflow, max length allowed '%d'".formatted(NAME_MAX_LENGTH));
this.creationTimestamp = Instant.now();
}

public boolean sameName(String name) {
return this.name.equalsIgnoreCase(name);
}

@Embeddable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.myfinbox.expense.adapter.web
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import io.myfinbox.TestServerApplication
import io.myfinbox.expense.DataSamples
import org.skyscreamer.jsonassert.JSONAssert
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
Expand All @@ -13,14 +14,16 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.jdbc.JdbcTestUtils
import spock.lang.Specification
import spock.lang.Tag

import static io.myfinbox.expense.DataSamples.newValidExpenseCategoryResource
import static org.skyscreamer.jsonassert.JSONCompareMode.LENIENT
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import static org.springframework.http.HttpStatus.CREATED
import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY
import static org.springframework.http.HttpMethod.PUT
import static org.springframework.http.HttpStatus.*
import static org.springframework.http.MediaType.APPLICATION_JSON

@Tag("integration")
Expand All @@ -34,12 +37,16 @@ class ExpenseCategoryControllerSpec extends Specification {
@Autowired
TestRestTemplate restTemplate

def cleanup() {
JdbcTestUtils.deleteFromTables(jdbcTemplate, 'expensecategory')
}

def "Should create a new category expense"() {
given: 'user wants to create a new category expense'
def request = newValidExpenseCategoryResource()

when: 'expense category is created'
def response = postNewExpenseCategory(request)
def response = postExpenseCategory(request)

then: 'response status is created'
assert response.getStatusCode() == CREATED
Expand All @@ -52,11 +59,11 @@ class ExpenseCategoryControllerSpec extends Specification {
}

def "should fail creation when request has validation failures"() {
given: 'user wants to create a new expense'
given: 'user wants to create a new expense category'
var request = '{}'

when: 'expense fails to create'
var response = postNewExpenseCategory(request)
var response = postExpenseCategory(request)

then: 'response has status code unprocessable entity'
assert response.getStatusCode() == UNPROCESSABLE_ENTITY
Expand All @@ -65,8 +72,46 @@ class ExpenseCategoryControllerSpec extends Specification {
JSONAssert.assertEquals(expectedCreationFailure(), response.getBody(), LENIENT)
}

private postNewExpenseCategory(String request) {
restTemplate.postForEntity('/expenses/categories', entityRequest(request), String.class)
@Sql('/expense/web/expensecategory-create.sql')
def "should update an existing category expense name"() {
given: 'user wants to update an existing category name'
def request = newValidExpenseCategoryResource(name: 'NEW name')

when: 'expense category name is updated'
def response = putExpenseCategory(request)

then: 'response status is ok'
assert response.getStatusCode() == OK

and: 'response body contains updated resource'
JSONAssert.assertEquals(newValidExpenseCategoryResource([name: "NEW name"]), response.getBody(), LENIENT)
}

def "Should fail update when category not found"() {
given: 'user wants to update an existing category'
var request = newValidExpenseCategoryResource()

when: 'expense fails to create'
var response = putExpenseCategory(request)

then: 'status code is not found'
assert response.getStatusCode() == NOT_FOUND

and: 'response body contains not found failure response'
JSONAssert.assertEquals(expectUpdateNotFoundFailure(), response.getBody(), LENIENT)
}

private postExpenseCategory(String request) {
restTemplate.postForEntity('/expenses/category', entityRequest(request), String.class)
}

private putExpenseCategory(String request) {
restTemplate.exchange(
"/expenses/category/${DataSamples.accountId.toString()}",
PUT,
entityRequest(request),
String.class
)
}

def entityRequest(String req) {
Expand All @@ -92,4 +137,12 @@ class ExpenseCategoryControllerSpec extends Specification {
def failureAsMap = new JsonSlurper().parse(new ClassPathResource(filePath).getFile())
JsonOutput.toJson(failureAsMap)
}

def expectUpdateNotFoundFailure() {
JsonOutput.toJson([
status : 404,
errorCode: 'NOT_FOUND',
message : 'Category not found.'
])
}
}
Loading

0 comments on commit 9b474c5

Please sign in to comment.