Skip to content

Commit dc49bd7

Browse files
feat: add API definition PATCH for v4 APIs
1 parent 884199d commit dc49bd7

File tree

4 files changed

+386
-0
lines changed

4 files changed

+386
-0
lines changed

gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/java/io/gravitee/rest/api/management/v2/rest/mapper/ApiMapper.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,9 @@ io.gravitee.rest.api.management.v2.rest.model.ApiV1 mapToV1(
295295
io.gravitee.apim.core.api.model.crd.PageCRD map(Page crd);
296296

297297
// UpdateApi
298+
/** Map full ApiV4 (e.g. from patched export) to UpdateApiV4 for use with the same update path as PUT .../apis/{apiId}. */
299+
UpdateApiV4 mapToUpdateApiV4(ApiV4 api);
300+
298301
@Mapping(target = "listeners", qualifiedByName = "toHttpListeners")
299302
@Mapping(target = "id", expression = "java(apiId)")
300303
UpdateApiEntity map(UpdateApiV4 updateApi, String apiId);

gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/java/io/gravitee/rest/api/management/v2/rest/resource/api/ApiResource.java

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import static java.util.Collections.emptyList;
2222
import static java.util.Collections.singletonList;
2323

24+
import com.fasterxml.jackson.core.JsonProcessingException;
2425
import io.gravitee.apim.core.api.model.UpdateNativeApi;
2526
import io.gravitee.apim.core.api.model.crd.IDExportStrategy;
2627
import io.gravitee.apim.core.api.model.utils.MigrationResult;
@@ -41,8 +42,10 @@
4142
import io.gravitee.common.component.Lifecycle;
4243
import io.gravitee.common.data.domain.Page;
4344
import io.gravitee.common.http.MediaType;
45+
import io.gravitee.definition.model.DefinitionVersion;
4446
import io.gravitee.definition.model.Proxy;
4547
import io.gravitee.definition.model.VirtualHost;
48+
import io.gravitee.definition.model.v4.ApiType;
4649
import io.gravitee.definition.model.v4.listener.Listener;
4750
import io.gravitee.definition.model.v4.listener.ListenerType;
4851
import io.gravitee.definition.model.v4.listener.http.HttpListener;
@@ -59,6 +62,7 @@
5962
import io.gravitee.rest.api.management.v2.rest.model.ApiTransferOwnership;
6063
import io.gravitee.rest.api.management.v2.rest.model.DuplicateApiOptions;
6164
import io.gravitee.rest.api.management.v2.rest.model.Error;
65+
import io.gravitee.rest.api.management.v2.rest.model.ExportApiV4;
6266
import io.gravitee.rest.api.management.v2.rest.model.MigrationReportResponses;
6367
import io.gravitee.rest.api.management.v2.rest.model.MigrationReportResponsesIssuesInner;
6468
import io.gravitee.rest.api.management.v2.rest.model.MigrationStateType;
@@ -85,6 +89,7 @@
8589
import io.gravitee.rest.api.management.v2.rest.resource.param.LifecycleAction;
8690
import io.gravitee.rest.api.management.v2.rest.resource.param.PaginationParam;
8791
import io.gravitee.rest.api.model.InlinePictureEntity;
92+
import io.gravitee.rest.api.model.JsonPatch;
8893
import io.gravitee.rest.api.model.MembershipMemberType;
8994
import io.gravitee.rest.api.model.RoleEntity;
9095
import io.gravitee.rest.api.model.SubscriptionEntity;
@@ -110,6 +115,7 @@
110115
import io.gravitee.rest.api.security.utils.ImageUtils;
111116
import io.gravitee.rest.api.service.ApiDuplicatorService;
112117
import io.gravitee.rest.api.service.ApplicationService;
118+
import io.gravitee.rest.api.service.JsonPatchService;
113119
import io.gravitee.rest.api.service.MembershipService;
114120
import io.gravitee.rest.api.service.ParameterService;
115121
import io.gravitee.rest.api.service.SubscriptionService;
@@ -120,14 +126,19 @@
120126
import io.gravitee.rest.api.service.exceptions.ForbiddenAccessException;
121127
import io.gravitee.rest.api.service.exceptions.ForbiddenFeatureException;
122128
import io.gravitee.rest.api.service.exceptions.InvalidLicenseException;
129+
import io.gravitee.rest.api.service.exceptions.JsonPatchTestFailedException;
123130
import io.gravitee.rest.api.service.exceptions.TechnicalManagementException;
124131
import io.gravitee.rest.api.service.exceptions.TransferOwnershipNotAllowedException;
125132
import io.gravitee.rest.api.service.v4.ApiDuplicateService;
126133
import io.gravitee.rest.api.service.v4.ApiImagesService;
127134
import io.gravitee.rest.api.service.v4.ApiLicenseService;
128135
import io.gravitee.rest.api.service.v4.ApiStateService;
129136
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;
130140
import io.swagger.v3.oas.annotations.parameters.RequestBody;
141+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
131142
import jakarta.annotation.Nullable;
132143
import jakarta.inject.Inject;
133144
import jakarta.validation.Valid;
@@ -136,7 +147,9 @@
136147
import jakarta.ws.rs.BeanParam;
137148
import jakarta.ws.rs.Consumes;
138149
import jakarta.ws.rs.DELETE;
150+
import jakarta.ws.rs.DefaultValue;
139151
import jakarta.ws.rs.GET;
152+
import jakarta.ws.rs.PATCH;
140153
import jakarta.ws.rs.POST;
141154
import jakarta.ws.rs.PUT;
142155
import jakarta.ws.rs.Path;
@@ -154,6 +167,7 @@
154167
import jakarta.ws.rs.core.UriInfo;
155168
import java.io.ByteArrayOutputStream;
156169
import java.util.ArrayList;
170+
import java.util.Collection;
157171
import java.util.List;
158172
import java.util.Map;
159173
import java.util.Objects;
@@ -202,6 +216,9 @@ public class ApiResource extends AbstractResource {
202216
@Inject
203217
private ExportApiUseCase exportApiUseCase;
204218

219+
@Inject
220+
private JsonPatchService jsonPatchService;
221+
205222
@Inject
206223
private SubscriptionService subscriptionService;
207224

@@ -525,6 +542,112 @@ public Response exportApiDefinition(
525542
.build();
526543
}
527544

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+
528651
@GET
529652
@Path("/_export/crd")
530653
@Produces(YamlWriter.MEDIA_TYPE)

0 commit comments

Comments
 (0)