Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/src/concepts/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,23 @@ curl -X PUT http://localhost:8084/api/v1/entities/web-service/my-web-service \
}
```

### Update Response Codes
## Partially Updating an Entity

Use `PATCH` when only selected fields should change. Fields omitted from the
request remain unchanged. Properties and relations supplied in the request are
merged with the existing values by name.

```text
PATCH /api/v1/entities/{templateIdentifier}/{entityIdentifier}
```

```bash
curl -X PATCH http://localhost:8084/api/v1/entities/web-service/my-web-service \
-H "Content-Type: application/json" \
-d '{"properties": {"port": "9090"}}'
```

### Patch Response Codes

| Code | Description |
|-------|--------------------------------------------------------|
Expand Down
72 changes: 72 additions & 0 deletions docs/src/static/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,60 @@ paths:
"*/*":
schema:
"$ref": "#/components/schemas/ErrorResponse"
patch:
tags:
- Entities Management
summary: Partially update an existing entity
description: Partially update an existing entity in the system with the provided information
operationId: patchEntity
parameters:
- name: templateIdentifier
in: path
required: true
schema:
type: string
minLength: 1
- name: entityIdentifier
in: path
required: true
schema:
type: string
minLength: 1
requestBody:
content:
application/json:
schema:
"$ref": "#/components/schemas/EntityPatchDtoIn"
required: true
responses:
'200':
description: Entity updated successfully
content:
"*/*":
schema:
"$ref": "#/components/schemas/EntityDtoOut"
'400':
description: Invalid entity data provided
content:
"*/*":
schema:
"$ref": "#/components/schemas/ErrorResponse"
'401':
description: Unauthorized - Missing or invalid token
'403':
description: Insufficient rights
'404':
description: Entity not found with the provided identifier
content:
"*/*":
schema:
"$ref": "#/components/schemas/ErrorResponse"
'500':
description: Unexpected server-side failure
content:
"*/*":
schema:
"$ref": "#/components/schemas/ErrorResponse"
delete:
tags:
- Entities Management
Expand Down Expand Up @@ -1513,6 +1567,24 @@ components:
"$ref": "#/components/schemas/RelationDtoIn"
required:
- name
EntityPatchDtoIn:
type: object
description: Input DTO for partially updating an entity. Omitted fields remain unchanged.
properties:
name:
type: string
description: Name of the entity
example: my-web-service-updated
properties:
type: object
additionalProperties:
type: string
description: Properties to merge with the existing entity properties
relations:
type: array
description: Relations to merge with the existing entity relations
items:
"$ref": "#/components/schemas/RelationDtoIn"
RelationDtoIn:
type: object
description: Input DTO for an entity relation instance
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.decathlon.idp_core.domain.model.entity;

import java.util.List;

/// Nullable fields representing the changes in a partial entity update.
///
/// A null field means that the existing value must be preserved. Validation is
/// performed on the complete [Entity] after this patch has been applied.
public record EntityPatch(String name, List<Property> properties, List<Relation> relations) {

/// Creates a patch from the entity-shaped payload produced by ingestion.
///
/// @param entity entity payload containing the fields to apply
/// @return patch containing the entity's mutable fields
public static EntityPatch fromEntity(Entity entity) {
return new EntityPatch(entity.name(), entity.properties(), entity.relations());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import com.decathlon.idp_core.domain.model.entity.Entity;
import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey;
import com.decathlon.idp_core.domain.model.entity.EntityFilter;
import com.decathlon.idp_core.domain.model.entity.EntityPatch;
import com.decathlon.idp_core.domain.model.entity.EntitySummary;
import com.decathlon.idp_core.domain.model.entity.Property;
import com.decathlon.idp_core.domain.model.entity.Relation;
Expand Down Expand Up @@ -213,15 +214,15 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier,
///
/// @param templateIdentifier template identifier from the request path
/// @param entityIdentifier entity identifier from the request path
/// @param patchData validated entity patch payload
/// @param patchData nullable entity patch payload
/// @return persisted updated entity
/// @throws EntityTemplateNotFoundException when template doesn't exist
/// @throws EntityNotFoundException when target entity doesn't exist
/// @throws EntityValidationException when payload violates
/// template constraints
@Transactional
public Entity patchEntity(String templateIdentifier, String entityIdentifier,
@Valid Entity patchData) {
EntityPatch patchData) {

EntityTemplate template = entityTemplateService
.getEntityTemplateByIdentifier(templateIdentifier);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public CorsConfigurationSource corsConfigurationSource() {
configuration.setAllowedOriginPatterns(corsProperties.allowedOriginPatterns());
}

configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ public class SwaggerDescription {

public static final String ENDPOINT_PUT_ENTITY_SUMMARY = "Update an existing entity";
public static final String ENDPOINT_PUT_ENTITY_DESCRIPTION = "Update an existing entity in the system with the provided information";
public static final String ENDPOINT_PATCH_ENTITY_SUMMARY = "Partially update an existing entity";
public static final String ENDPOINT_PATCH_ENTITY_DESCRIPTION = "Partially update an existing entity in the system with the provided information";
public static final String ENDPOINT_DELETE_ENTITY_SUMMARY = "Delete an existing entity";
public static final String ENDPOINT_DELETE_ENTITY_DESCRIPTION = "Delete an entity from the system using its template and entity identifiers. This operation removes the entity and automatically cleans up any relations from other entities that reference it.";

Expand Down Expand Up @@ -164,6 +166,7 @@ public class SwaggerDescription {
public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity";
public static final String SCHEMA_ENTITY_CREATE_IN = "Input DTO for creating an entity";
public static final String SCHEMA_ENTITY_UPDATE_IN = "Input DTO for updating an entity";
public static final String SCHEMA_ENTITY_PATCH_IN = "Input DTO for partially updating an entity";
public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance";
public static final String SCHEMA_ENTITY_SEARCH_REQUEST_IN = "Request body for the POST /api/v1/entities/search endpoint";
public static final String SCHEMA_FILTER_NODE = "A node in the search filter tree. Either a logical group (connector + criteria) or a leaf criterion (field + operation + value).";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_SUMMARY;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PATCH_ENTITY_DESCRIPTION;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PATCH_ENTITY_SUMMARY;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_DESCRIPTION;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_SEARCH_DESCRIPTION;
Expand Down Expand Up @@ -63,6 +65,7 @@
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
Expand All @@ -74,6 +77,7 @@

import com.decathlon.idp_core.domain.model.entity.Entity;
import com.decathlon.idp_core.domain.model.entity.EntityFilter;
import com.decathlon.idp_core.domain.model.entity.EntityPatch;
import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode;
import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode;
import com.decathlon.idp_core.domain.model.search.PaginatedResult;
Expand All @@ -86,6 +90,7 @@
import com.decathlon.idp_core.domain.service.search.SearchFilterParser;
import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityCreateDtoIn;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityPatchDtoIn;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntitySearchRequestDtoIn;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityUpdateDtoIn;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut;
Expand Down Expand Up @@ -295,6 +300,32 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi
return entityDtoOutMapper.fromEntity(updatedEntity);
}

/// Partially updates an existing entity for the specified template.
///
/// Omitted fields are kept unchanged. Properties and relations supplied in
/// the request are merged by name with the existing entity.
@Operation(summary = ENDPOINT_PATCH_ENTITY_SUMMARY, description = ENDPOINT_PATCH_ENTITY_DESCRIPTION)
@ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = {
@Content(schema = @Schema(implementation = EntityDtoOut.class))})
@ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {
@Content(schema = @Schema(implementation = ErrorResponse.class))})
@ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content)
@ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content)
@ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = {
@Content(schema = @Schema(implementation = ErrorResponse.class))})
@ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {
@Content(schema = @Schema(implementation = ErrorResponse.class))})
@PatchMapping("/{templateIdentifier}/{entityIdentifier}")
@ResponseStatus(OK)
public EntityDtoOut patchEntity(@NotBlank @PathVariable String templateIdentifier,
@NotBlank @PathVariable String entityIdentifier,
@Valid @RequestBody EntityPatchDtoIn patchDtoIn) {
EntityPatch patchData = entityDtoInMapper.fromPatchEntityDtoInToEntity(patchDtoIn);
Entity updatedEntity = entityService.patchEntity(templateIdentifier, entityIdentifier,
patchData);
return entityDtoOutMapper.fromEntity(updatedEntity);
}

/// Deletes an existing entity identified by template and entity identifiers.
///
/// **API contract:** Validates the template and entity exist, cleans up
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;

import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_NAME;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_PROPERTIES;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATIONS;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_PATCH_IN;

import java.util.List;
import java.util.Map;

import jakarta.validation.Valid;

import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/// Input DTO for partially updating an entity.
///
/// Omitted fields remain unchanged. An explicitly supplied empty properties map
/// or relations list is passed through as an empty collection.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(SnakeCaseStrategy.class)
@Schema(description = SCHEMA_ENTITY_PATCH_IN)
public class EntityPatchDtoIn {

@Schema(description = FIELD_ENTITY_NAME, example = "my-web-service-updated")
private String name;

@Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\"}")
private Map<String, String> properties;

@Valid
@Schema(description = FIELD_ENTITY_RELATIONS)
private List<EntityDtoInCommonFields.RelationDtoIn> relations;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
import org.springframework.stereotype.Component;

import com.decathlon.idp_core.domain.model.entity.Entity;
import com.decathlon.idp_core.domain.model.entity.EntityPatch;
import com.decathlon.idp_core.domain.model.entity.Property;
import com.decathlon.idp_core.domain.model.entity.Relation;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityCreateDtoIn;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoInCommonFields;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityPatchDtoIn;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityUpdateDtoIn;

/// Adapter mapper for converting API request DTOs to domain [Entity] objects.
Expand Down Expand Up @@ -53,6 +55,23 @@ public Entity fromPutEntityDtoInToEntity(EntityUpdateDtoIn entityUpdateDtoIn,
entityIdentifier);
}

/// Converts a partial entity update request DTO to a domain entity.
///
/// Null fields are preserved so the domain service can merge them with the
/// existing entity.
public EntityPatch fromPatchEntityDtoInToEntity(EntityPatchDtoIn patchDtoIn) {
List<Property> properties = patchDtoIn.getProperties() == null
? null
: patchDtoIn.getProperties().entrySet().stream()
.map(entry -> new Property(null, entry.getKey(), entry.getValue())).toList();
List<Relation> relations = patchDtoIn.getRelations() == null
? null
: patchDtoIn.getRelations().stream().map(relDto -> new Relation(null, relDto.getName(),
null, relDto.getTargetEntityIdentifiers())).toList();

return new EntityPatch(patchDtoIn.getName(), properties, relations);
}

/// Shared helper method to build the domain entity from common fields.
private Entity buildEntity(EntityDtoInCommonFields commonFields, String entityTemplateIdentifier,
String identifier) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException;
import com.decathlon.idp_core.domain.model.entity.Entity;
import com.decathlon.idp_core.domain.model.entity.EntityPatch;
import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
import com.decathlon.idp_core.domain.port.MappingEnginePort;
Expand Down Expand Up @@ -82,7 +83,8 @@ private void handleUpdate(Entity entity) {
if (entityService.entityExists(entity.templateIdentifier(), entity.identifier())) {
log.debug("Patching entity {} for template: {}", entity.identifier(),
entity.templateIdentifier());
entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), entity);
entityService.patchEntity(entity.templateIdentifier(), entity.identifier(),
EntityPatch.fromEntity(entity));
} else {
log.debug("Creating entity {} for template: {}", entity.identifier(),
entity.templateIdentifier());
Expand All @@ -98,14 +100,13 @@ private void handleUpdate(Entity entity) {
///
/// @param entity the entity to Update properties for
private void handleUpdateProperties(Entity entity) {
Entity propertiesOnlyEntity = new Entity(entity.id(), entity.templateIdentifier(),
entity.name(), entity.identifier(), entity.properties(), List.of());
EntityPatch propertiesPatch = new EntityPatch(entity.name(), entity.properties(), List.of());

if (entityService.entityExists(entity.templateIdentifier(), entity.identifier())) {
entityService.patchEntity(entity.templateIdentifier(), entity.identifier(),
propertiesOnlyEntity);
entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), propertiesPatch);
} else {
entityService.createEntity(propertiesOnlyEntity);
entityService.createEntity(new Entity(null, entity.templateIdentifier(), entity.name(),
entity.identifier(), entity.properties(), List.of()));
}
}

Expand All @@ -121,11 +122,9 @@ private void handleUpdateRelations(Entity entity) {
throw new EntityNotFoundException(entity.templateIdentifier(), entity.identifier());
}
// Strip properties before invoking entity service
Entity relationsOnlyEntity = new Entity(null, entity.templateIdentifier(), null,
entity.identifier(), List.of(), entity.relations());
EntityPatch relationsPatch = new EntityPatch(null, List.of(), entity.relations());

entityService.patchEntity(entity.templateIdentifier(), entity.identifier(),
relationsOnlyEntity);
entityService.patchEntity(entity.templateIdentifier(), entity.identifier(), relationsPatch);

}

Expand Down
Loading
Loading