|
21 | 21 | import static java.util.Collections.emptyList; |
22 | 22 | import static java.util.Collections.singletonList; |
23 | 23 |
|
| 24 | +import com.fasterxml.jackson.core.JsonProcessingException; |
24 | 25 | import io.gravitee.apim.core.api.model.UpdateNativeApi; |
25 | 26 | import io.gravitee.apim.core.api.model.crd.IDExportStrategy; |
26 | 27 | import io.gravitee.apim.core.api.model.utils.MigrationResult; |
|
41 | 42 | import io.gravitee.common.component.Lifecycle; |
42 | 43 | import io.gravitee.common.data.domain.Page; |
43 | 44 | import io.gravitee.common.http.MediaType; |
| 45 | +import io.gravitee.definition.model.DefinitionVersion; |
44 | 46 | import io.gravitee.definition.model.Proxy; |
45 | 47 | import io.gravitee.definition.model.VirtualHost; |
| 48 | +import io.gravitee.definition.model.v4.ApiType; |
46 | 49 | import io.gravitee.definition.model.v4.listener.Listener; |
47 | 50 | import io.gravitee.definition.model.v4.listener.ListenerType; |
48 | 51 | import io.gravitee.definition.model.v4.listener.http.HttpListener; |
|
59 | 62 | import io.gravitee.rest.api.management.v2.rest.model.ApiTransferOwnership; |
60 | 63 | import io.gravitee.rest.api.management.v2.rest.model.DuplicateApiOptions; |
61 | 64 | import io.gravitee.rest.api.management.v2.rest.model.Error; |
| 65 | +import io.gravitee.rest.api.management.v2.rest.model.ExportApiV4; |
62 | 66 | import io.gravitee.rest.api.management.v2.rest.model.MigrationReportResponses; |
63 | 67 | import io.gravitee.rest.api.management.v2.rest.model.MigrationReportResponsesIssuesInner; |
64 | 68 | import io.gravitee.rest.api.management.v2.rest.model.MigrationStateType; |
|
85 | 89 | import io.gravitee.rest.api.management.v2.rest.resource.param.LifecycleAction; |
86 | 90 | import io.gravitee.rest.api.management.v2.rest.resource.param.PaginationParam; |
87 | 91 | import io.gravitee.rest.api.model.InlinePictureEntity; |
| 92 | +import io.gravitee.rest.api.model.JsonPatch; |
88 | 93 | import io.gravitee.rest.api.model.MembershipMemberType; |
89 | 94 | import io.gravitee.rest.api.model.RoleEntity; |
90 | 95 | import io.gravitee.rest.api.model.SubscriptionEntity; |
|
110 | 115 | import io.gravitee.rest.api.security.utils.ImageUtils; |
111 | 116 | import io.gravitee.rest.api.service.ApiDuplicatorService; |
112 | 117 | import io.gravitee.rest.api.service.ApplicationService; |
| 118 | +import io.gravitee.rest.api.service.JsonPatchService; |
113 | 119 | import io.gravitee.rest.api.service.MembershipService; |
114 | 120 | import io.gravitee.rest.api.service.ParameterService; |
115 | 121 | import io.gravitee.rest.api.service.SubscriptionService; |
|
120 | 126 | import io.gravitee.rest.api.service.exceptions.ForbiddenAccessException; |
121 | 127 | import io.gravitee.rest.api.service.exceptions.ForbiddenFeatureException; |
122 | 128 | import io.gravitee.rest.api.service.exceptions.InvalidLicenseException; |
| 129 | +import io.gravitee.rest.api.service.exceptions.JsonPatchTestFailedException; |
123 | 130 | import io.gravitee.rest.api.service.exceptions.TechnicalManagementException; |
124 | 131 | import io.gravitee.rest.api.service.exceptions.TransferOwnershipNotAllowedException; |
125 | 132 | import io.gravitee.rest.api.service.v4.ApiDuplicateService; |
126 | 133 | import io.gravitee.rest.api.service.v4.ApiImagesService; |
127 | 134 | import io.gravitee.rest.api.service.v4.ApiLicenseService; |
128 | 135 | import io.gravitee.rest.api.service.v4.ApiStateService; |
129 | 136 | import io.gravitee.rest.api.service.v4.ApiWorkflowStateService; |
| 137 | +import io.swagger.v3.oas.annotations.Operation; |
| 138 | +import io.swagger.v3.oas.annotations.media.Content; |
| 139 | +import io.swagger.v3.oas.annotations.media.Schema; |
130 | 140 | import io.swagger.v3.oas.annotations.parameters.RequestBody; |
| 141 | +import io.swagger.v3.oas.annotations.responses.ApiResponse; |
131 | 142 | import jakarta.annotation.Nullable; |
132 | 143 | import jakarta.inject.Inject; |
133 | 144 | import jakarta.validation.Valid; |
|
136 | 147 | import jakarta.ws.rs.BeanParam; |
137 | 148 | import jakarta.ws.rs.Consumes; |
138 | 149 | import jakarta.ws.rs.DELETE; |
| 150 | +import jakarta.ws.rs.DefaultValue; |
139 | 151 | import jakarta.ws.rs.GET; |
| 152 | +import jakarta.ws.rs.PATCH; |
140 | 153 | import jakarta.ws.rs.POST; |
141 | 154 | import jakarta.ws.rs.PUT; |
142 | 155 | import jakarta.ws.rs.Path; |
|
154 | 167 | import jakarta.ws.rs.core.UriInfo; |
155 | 168 | import java.io.ByteArrayOutputStream; |
156 | 169 | import java.util.ArrayList; |
| 170 | +import java.util.Collection; |
157 | 171 | import java.util.List; |
158 | 172 | import java.util.Map; |
159 | 173 | import java.util.Objects; |
@@ -202,6 +216,9 @@ public class ApiResource extends AbstractResource { |
202 | 216 | @Inject |
203 | 217 | private ExportApiUseCase exportApiUseCase; |
204 | 218 |
|
| 219 | + @Inject |
| 220 | + private JsonPatchService jsonPatchService; |
| 221 | + |
205 | 222 | @Inject |
206 | 223 | private SubscriptionService subscriptionService; |
207 | 224 |
|
@@ -525,6 +542,112 @@ public Response exportApiDefinition( |
525 | 542 | .build(); |
526 | 543 | } |
527 | 544 |
|
| 545 | + @PATCH |
| 546 | + @Path("/definition") |
| 547 | + @Consumes(MediaType.APPLICATION_JSON) |
| 548 | + @Produces(MediaType.APPLICATION_JSON) |
| 549 | + @Operation( |
| 550 | + summary = "Partially update a V4 HTTP Proxy API definition (JSON Patch)", |
| 551 | + description = """ |
| 552 | + JSON Patch (RFC 6902) on the API export document—the same structure as **GET** `.../apis/{apiId}/_export/definition`. \ |
| 553 | + Intended for **v2 → v4 management parity**: small, programmatic updates without a full **PUT** body. |
| 554 | +
|
| 555 | + **Scope:** V4 HTTP Proxy only (`definitionVersion=V4`, `type=proxy`). Message, Native/TCP, Federated, and other kinds \ |
| 556 | + return **400**. |
| 557 | +
|
| 558 | + **Persisted payload:** Only the **`api`** object is mapped to **UpdateApiV4** and saved, using the **same update path \ |
| 559 | + and validation as PUT** `.../apis/{apiId}` for V4. Patches under **plans**, **metadata**, **members**, **pages**, \ |
| 560 | + **apiPicture**, etc. are **not** applied (use dedicated endpoints where available). |
| 561 | +
|
| 562 | + **dryRun:** `dryRun=true` applies the patch **in memory** and returns the patched JSON **without persisting**. \ |
| 563 | + The response may still contain patched envelope paths that **would not** be written on a real update. |
| 564 | +
|
| 565 | + **If-Match:** Optional; when present it must match the API revision ETag (same as **PUT** `.../apis/{apiId}`). \ |
| 566 | + Mismatch → **412 Precondition Failed**. |
| 567 | +
|
| 568 | + **Permissions:** **API_DEFINITION[UPDATE]** *or* **API_GATEWAY_DEFINITION[UPDATE]** (same OR rule as V4 PUT). \ |
| 569 | + Without gateway-definition update (unless primary owner or admin), **listeners** from the patch are ignored.""", |
| 570 | + tags = { "API Definition" } |
| 571 | + ) |
| 572 | + @ApiResponse( |
| 573 | + responseCode = "200", |
| 574 | + description = "API successfully updated with json patches (or result of dry run)", |
| 575 | + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ExportApiV4.class)) |
| 576 | + ) |
| 577 | + @ApiResponse(responseCode = "204", description = "Patch test failed (no change applied)") |
| 578 | + @ApiResponse(responseCode = "400", description = "API kind does not support definition PATCH") |
| 579 | + @ApiResponse(responseCode = "412", description = "Precondition failed (If-Match does not match current revision)") |
| 580 | + @ApiResponse(responseCode = "500", description = "Internal server error") |
| 581 | + @Permissions( |
| 582 | + { |
| 583 | + @Permission(value = RolePermission.API_DEFINITION, acls = RolePermissionAction.UPDATE), |
| 584 | + @Permission(value = RolePermission.API_GATEWAY_DEFINITION, acls = RolePermissionAction.UPDATE), |
| 585 | + } |
| 586 | + ) |
| 587 | + public Response patchApiDefinition( |
| 588 | + @Context final HttpHeaders headers, |
| 589 | + @PathParam("apiId") String apiId, |
| 590 | + @RequestBody(required = true) @Valid @NotNull final Collection<JsonPatch> patches, |
| 591 | + @QueryParam("dryRun") @DefaultValue("false") final boolean dryRun |
| 592 | + ) { |
| 593 | + final GenericApiEntity genericEntity = getGenericApiEntityById(apiId, false, false, false, false); |
| 594 | + |
| 595 | + if ( |
| 596 | + !(genericEntity instanceof ApiEntity api) || |
| 597 | + genericEntity.getDefinitionVersion() != DefinitionVersion.V4 || |
| 598 | + api.getType() != ApiType.PROXY |
| 599 | + ) { |
| 600 | + return Response.status(Response.Status.BAD_REQUEST) |
| 601 | + .entity( |
| 602 | + new Error() |
| 603 | + .httpStatus(Response.Status.BAD_REQUEST.getStatusCode()) |
| 604 | + .message( |
| 605 | + "JSON Patch on the API definition is only available for V4 HTTP Proxy APIs; this API type is not supported." |
| 606 | + ) |
| 607 | + ) |
| 608 | + .build(); |
| 609 | + } |
| 610 | + |
| 611 | + evaluateIfMatch(headers, Long.toString(genericEntity.getUpdatedAt().getTime())); |
| 612 | + |
| 613 | + var exportOutput = exportApiUseCase.execute(ExportApiUseCase.Input.of(apiId, getAuditInfo(), emptyList())); |
| 614 | + String definitionJson = patchDefinitionToJson(ImportExportApiMapper.INSTANCE.map(exportOutput.definition())); |
| 615 | + |
| 616 | + try { |
| 617 | + String definitionModified = jsonPatchService.execute(definitionJson, patches); |
| 618 | + if (dryRun) { |
| 619 | + return Response.ok(definitionModified).build(); |
| 620 | + } |
| 621 | + |
| 622 | + ExportApiV4 patchedExport = ImportExportApiMapper.INSTANCE.definitionToExportApiV4(definitionModified); |
| 623 | + UpdateApiV4 updateApiV4 = ApiMapper.INSTANCE.mapToUpdateApiV4(patchedExport.getApi()); |
| 624 | + |
| 625 | + ApiEntity currentApi = (ApiEntity) genericEntity; |
| 626 | + ApiEntity updatedEntity = updateHttpApiV4(currentApi, updateApiV4); |
| 627 | + |
| 628 | + var reExport = exportApiUseCase.execute(ExportApiUseCase.Input.of(apiId, getAuditInfo(), emptyList())); |
| 629 | + ExportApiV4 updatedDefinition = ImportExportApiMapper.INSTANCE.map(reExport.definition()); |
| 630 | + |
| 631 | + return Response.ok(updatedDefinition) |
| 632 | + .tag(Long.toString(updatedEntity.getUpdatedAt().getTime())) |
| 633 | + .lastModified(updatedEntity.getUpdatedAt()) |
| 634 | + .build(); |
| 635 | + } catch (JsonPatchTestFailedException e) { |
| 636 | + return Response.noContent() |
| 637 | + .tag(Long.toString(genericEntity.getUpdatedAt().getTime())) |
| 638 | + .lastModified(genericEntity.getUpdatedAt()) |
| 639 | + .build(); |
| 640 | + } |
| 641 | + } |
| 642 | + |
| 643 | + private static String patchDefinitionToJson(ExportApiV4 exportApiV4) { |
| 644 | + try { |
| 645 | + return ImportExportApiMapper.JSON_MAPPER.writeValueAsString(exportApiV4); |
| 646 | + } catch (JsonProcessingException e) { |
| 647 | + throw new TechnicalManagementException("Failed to serialize API definition", e); |
| 648 | + } |
| 649 | + } |
| 650 | + |
528 | 651 | @GET |
529 | 652 | @Path("/_export/crd") |
530 | 653 | @Produces(YamlWriter.MEDIA_TYPE) |
|
0 commit comments