diff --git a/docs/MVP.md b/docs/MVP.md index 6277a25..33a9861 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -54,7 +54,7 @@ Deliver a working, tested API that supports: ### 4. **Entity Management** - [x] `GET /api/v1/entities` – Retrieve all entities. - [x] `GET /api/v1/entities/{id}` – Retrieve a specific entity by ID. -- [ ] `POST /api/v1/entities` – Create a new entity. _(Currently implemented as `POST /api/v1/entities/{name}` using a path variable; alignment to the request-body form documented here is tracked in [#135](https://github.com/Preponderous-Software/viron/issues/135).)_ +- [x] `POST /api/v1/entities` – Create a new entity (request body: `{ "name": "..." }`). - [x] `DELETE /api/v1/entities/{id}` – Delete an entity. --- diff --git a/docs/openapi/viron-api.json b/docs/openapi/viron-api.json index 5d48032..f28d75b 100644 --- a/docs/openapi/viron-api.json +++ b/docs/openapi/viron-api.json @@ -32,7 +32,7 @@ } }, "responses": { - "200": { + "201": { "description": "Environment created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvironmentDTO" } } @@ -162,6 +162,148 @@ "responses": { "200": { "description": "Entity removed" } } } }, + "/api/v1/entities": { + "get": { + "summary": "Get all entities", + "responses": { + "200": { + "description": "List of entities", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/EntityDTO" } + } + } + } + } + } + }, + "post": { + "summary": "Create a new entity", + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/CreateEntityRequest" } } + } + }, + "responses": { + "201": { + "description": "Entity created", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/EntityDTO" } } + } + } + } + } + }, + "/api/v1/entities/{id}": { + "get": { + "summary": "Get entity by ID", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], + "responses": { + "200": { + "description": "Entity details", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/EntityDTO" } } + } + } + } + }, + "delete": { + "summary": "Delete entity", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], + "responses": { "204": { "description": "Entity deleted" } } + } + }, + "/api/v1/entities/{id}/name": { + "patch": { + "summary": "Update entity name", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/UpdateEntityNameRequest" } } + } + }, + "responses": { "200": { "description": "Name updated" } } + } + }, + "/api/v1/entities/environment/{environmentId}": { + "get": { + "summary": "Get entities in an environment", + "parameters": [{ "name": "environmentId", "in": "path", "required": true, "schema": { "type": "integer" } }], + "responses": { + "200": { + "description": "List of entities in the environment", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/EntityDTO" } + } + } + } + } + } + } + }, + "/api/v1/entities/grid/{gridId}": { + "get": { + "summary": "Get entities in a grid", + "parameters": [{ "name": "gridId", "in": "path", "required": true, "schema": { "type": "integer" } }], + "responses": { + "200": { + "description": "List of entities in the grid", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/EntityDTO" } + } + } + } + } + } + } + }, + "/api/v1/entities/location/{locationId}": { + "get": { + "summary": "Get entities in a location", + "parameters": [{ "$ref": "#/components/parameters/LocationIdParam" }], + "responses": { + "200": { + "description": "List of entities in the location", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/EntityDTO" } + } + } + } + } + } + } + }, + "/api/v1/entities/unassigned": { + "get": { + "summary": "Get entities not in any location", + "responses": { + "200": { + "description": "List of entities not assigned to any location", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/EntityDTO" } + } + } + } + } + } + } + }, "/api/v1/debug/create-sample-data": { "post": { "summary": "Create sample environment and entities", @@ -235,8 +377,19 @@ "type": "object", "properties": { "entityId": { "type": "integer" }, - "name": { "type": "string" } + "name": { "type": "string" }, + "creationDate": { "type": "string" } } + }, + "CreateEntityRequest": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } } + }, + "UpdateEntityNameRequest": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } } } }, "parameters": { diff --git a/src/main/java/preponderous/viron/controllers/EntityController.java b/src/main/java/preponderous/viron/controllers/EntityController.java index a1bf6bb..6f8d20d 100644 --- a/src/main/java/preponderous/viron/controllers/EntityController.java +++ b/src/main/java/preponderous/viron/controllers/EntityController.java @@ -5,7 +5,9 @@ import org.springframework.http.HttpStatus; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import preponderous.viron.dto.CreateEntityRequest; import preponderous.viron.dto.EntityDto; +import preponderous.viron.dto.UpdateEntityNameRequest; import preponderous.viron.exceptions.NotFoundException; import preponderous.viron.exceptions.ServiceException; import preponderous.viron.factories.EntityFactory; @@ -13,8 +15,8 @@ import preponderous.viron.models.Entity; import preponderous.viron.repositories.EntityRepository; +import jakarta.validation.Valid; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; import java.util.List; @RestController @@ -64,10 +66,10 @@ public List getEntitiesNotInAnyLocation() { return entityMapper.toDtoList(entities); } - @PostMapping("/{name}") + @PostMapping @ResponseStatus(HttpStatus.CREATED) - public EntityDto createEntity(@PathVariable @NotBlank String name) { - Entity newEntity = entityFactory.createEntity(name); + public EntityDto createEntity(@Valid @RequestBody CreateEntityRequest request) { + Entity newEntity = entityFactory.createEntity(request.getName()); return entityMapper.toDto(newEntity); } @@ -82,8 +84,9 @@ public void deleteEntity(@PathVariable @Min(1) int id) { } } - @PatchMapping("/{id}/name/{name}") - public void updateEntityName(@PathVariable @Min(1) int id, @PathVariable @NotBlank String name) { + @PatchMapping("/{id}/name") + public void updateEntityName(@PathVariable @Min(1) int id, @Valid @RequestBody UpdateEntityNameRequest request) { + String name = request.getName(); if (entityRepository.findById(id).isEmpty()) { throw new NotFoundException("Entity not found with id: " + id); } diff --git a/src/main/java/preponderous/viron/dto/CreateEntityRequest.java b/src/main/java/preponderous/viron/dto/CreateEntityRequest.java new file mode 100644 index 0000000..1558d2b --- /dev/null +++ b/src/main/java/preponderous/viron/dto/CreateEntityRequest.java @@ -0,0 +1,17 @@ +package preponderous.viron.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Request body for creating an entity") +public class CreateEntityRequest { + @NotBlank + @Schema(description = "Name of the entity to create") + private String name; +} diff --git a/src/main/java/preponderous/viron/dto/UpdateEntityNameRequest.java b/src/main/java/preponderous/viron/dto/UpdateEntityNameRequest.java new file mode 100644 index 0000000..8123160 --- /dev/null +++ b/src/main/java/preponderous/viron/dto/UpdateEntityNameRequest.java @@ -0,0 +1,17 @@ +package preponderous.viron.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Request body for updating an entity's name") +public class UpdateEntityNameRequest { + @NotBlank + @Schema(description = "New name for the entity") + private String name; +} diff --git a/src/main/java/preponderous/viron/services/EntityService.java b/src/main/java/preponderous/viron/services/EntityService.java index 3eabf4d..2e071ac 100644 --- a/src/main/java/preponderous/viron/services/EntityService.java +++ b/src/main/java/preponderous/viron/services/EntityService.java @@ -6,6 +6,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import preponderous.viron.config.ServiceConfig; +import preponderous.viron.dto.CreateEntityRequest; +import preponderous.viron.dto.UpdateEntityNameRequest; import preponderous.viron.exceptions.EntityServiceException; import preponderous.viron.models.Entity; @@ -134,10 +136,9 @@ public List getEntitiesNotInAnyLocation() { public Entity createEntity(String name) { try { ResponseEntity response = restTemplate.postForEntity( - getBaseUrl() + "/{name}", - null, - Entity.class, - name + getBaseUrl(), + new CreateEntityRequest(name), + Entity.class ); if (response.getBody() == null) { @@ -165,11 +166,10 @@ public boolean deleteEntity(int id) { public boolean updateEntityName(int id, String name) { try { restTemplate.patchForObject( - getBaseUrl() + "/{id}/name/{name}", - null, + getBaseUrl() + "/{id}/name", + new UpdateEntityNameRequest(name), Void.class, - id, - name + id ); return true; } catch (Exception e) { diff --git a/src/main/python/preponderous/viron/services/entityService.py b/src/main/python/preponderous/viron/services/entityService.py index 1f21005..31a2609 100644 --- a/src/main/python/preponderous/viron/services/entityService.py +++ b/src/main/python/preponderous/viron/services/entityService.py @@ -51,7 +51,7 @@ def get_entities_not_in_any_location(self) -> List[Entity]: return [Entity(**entity) for entity in data] if data else [] def create_entity(self, name: str) -> Entity: - response = requests.post(f"{self.get_base_url()}/{name}") + response = requests.post(self.get_base_url(), json={"name": name}) response.raise_for_status() data = response.json() if not data: @@ -69,7 +69,7 @@ def delete_entity(self, entity_id: int) -> bool: def update_entity_name(self, entity_id: int, name: str) -> bool: try: - response = requests.patch(f"{self.get_base_url()}/{entity_id}/name/{name}") + response = requests.patch(f"{self.get_base_url()}/{entity_id}/name", json={"name": name}) response.raise_for_status() return True except Exception as e: diff --git a/src/test/java/preponderous/viron/controllers/EntityControllerTest.java b/src/test/java/preponderous/viron/controllers/EntityControllerTest.java index aa7608d..99b873a 100644 --- a/src/test/java/preponderous/viron/controllers/EntityControllerTest.java +++ b/src/test/java/preponderous/viron/controllers/EntityControllerTest.java @@ -7,6 +7,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import preponderous.viron.config.DbConfig; import preponderous.viron.database.DbInteractions; @@ -219,7 +220,7 @@ void getEntitiesNotInAnyLocation_EmptyList() throws Exception { .andExpect(jsonPath("$", hasSize(0))); } - // --- POST /api/v1/entities/{name} --- + // --- POST /api/v1/entities --- @Test void createEntity_ReturnsCreated() throws Exception { @@ -228,19 +229,34 @@ void createEntity_ReturnsCreated() throws Exception { when(entityFactory.createEntity("NewEntity")).thenReturn(entity); when(entityMapper.toDto(entity)).thenReturn(dto); - mockMvc.perform(post("/api/v1/entities/NewEntity")) + mockMvc.perform(post("/api/v1/entities") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"NewEntity\"}")) .andExpect(status().isCreated()) .andExpect(jsonPath("$.entityId").value(1)) .andExpect(jsonPath("$.name").value("NewEntity")) .andExpect(jsonPath("$.creationDate").value("2024-01-01")); } + @Test + void createEntity_BlankName_ReturnsBadRequest() throws Exception { + mockMvc.perform(post("/api/v1/entities") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); + + verify(entityFactory, never()).createEntity(anyString()); + } + @Test void createEntity_FactoryThrowsEntityCreationException() throws Exception { when(entityFactory.createEntity("BadEntity")) .thenThrow(new EntityCreationException("Creation failed")); - mockMvc.perform(post("/api/v1/entities/BadEntity")) + mockMvc.perform(post("/api/v1/entities") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"BadEntity\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.message").value("Creation failed")); @@ -251,7 +267,9 @@ void createEntity_FactoryThrowsRuntimeException() throws Exception { when(entityFactory.createEntity("ErrorEntity")) .thenThrow(new RuntimeException("Unexpected error")); - mockMvc.perform(post("/api/v1/entities/ErrorEntity")) + mockMvc.perform(post("/api/v1/entities") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"ErrorEntity\"}")) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.status").value(500)) .andExpect(jsonPath("$.message").isString()); @@ -289,22 +307,39 @@ void deleteEntity_RepositoryThrowsException() throws Exception { .andExpect(jsonPath("$.message").isString()); } - // --- PATCH /api/v1/entities/{id}/name/{name} --- + // --- PATCH /api/v1/entities/{id}/name --- @Test void updateEntityName_Success() throws Exception { when(entityRepository.findById(1)).thenReturn(Optional.of(new Entity(1, "OldName", "2024-01-01"))); when(entityRepository.updateName(1, "UpdatedName")).thenReturn(true); - mockMvc.perform(patch("/api/v1/entities/1/name/UpdatedName")) + mockMvc.perform(patch("/api/v1/entities/1/name") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"UpdatedName\"}")) .andExpect(status().isOk()); + + verify(entityRepository).updateName(1, "UpdatedName"); + } + + @Test + void updateEntityName_BlankName_ReturnsBadRequest() throws Exception { + mockMvc.perform(patch("/api/v1/entities/1/name") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); + + verify(entityRepository, never()).updateName(anyInt(), anyString()); } @Test void updateEntityName_NotFound() throws Exception { when(entityRepository.findById(999)).thenReturn(Optional.empty()); - mockMvc.perform(patch("/api/v1/entities/999/name/UpdatedName")) + mockMvc.perform(patch("/api/v1/entities/999/name") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"UpdatedName\"}")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.message").value("Entity not found with id: 999")); @@ -315,7 +350,9 @@ void updateEntityName_RepositoryThrowsException() throws Exception { when(entityRepository.findById(1)).thenReturn(Optional.of(new Entity(1, "OldName", "2024-01-01"))); when(entityRepository.updateName(1, "Name")).thenThrow(new RuntimeException("Database error")); - mockMvc.perform(patch("/api/v1/entities/1/name/Name")) + mockMvc.perform(patch("/api/v1/entities/1/name") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Name\"}")) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.status").value(500)) .andExpect(jsonPath("$.message").isString()); diff --git a/src/test/python/preponderous/viron/services/test_entityService.py b/src/test/python/preponderous/viron/services/test_entityService.py index 99da0bb..59b53db 100644 --- a/src/test/python/preponderous/viron/services/test_entityService.py +++ b/src/test/python/preponderous/viron/services/test_entityService.py @@ -115,7 +115,7 @@ def test_create_entity_success(mock_post): entity = service.create_entity("Entity1") assert isinstance(entity, Entity) assert entity.name == "Entity1" - mock_post.assert_called_once_with(f"{service.get_base_url()}/Entity1") + mock_post.assert_called_once_with(service.get_base_url(), json={"name": "Entity1"}) @patch('requests.delete') @@ -135,7 +135,7 @@ def test_update_entity_name_success(mock_patch): result = service.update_entity_name(1, "NewName") assert result is True - mock_patch.assert_called_once_with(f"{service.get_base_url()}/1/name/NewName") + mock_patch.assert_called_once_with(f"{service.get_base_url()}/1/name", json={"name": "NewName"}) # Error cases