8000 enhance: add yaml support to v2 by shawkins · Pull Request #55061 · keycloak/keycloak · GitHub
[go: up one dir, main page]

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
10 changes: 10 additions & 0 deletions docs/documentation/release_notes/topics/27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ This removes the need for a separate API call to discover the service account us
The dedicated `GET /admin/realms/\{realm}/clients/\{id}/service-account-user` endpoint has been removed.
See the upgrading guide for migration details.

== Manage clients as YAML with Admin API v2 (experimental)
// https://github.com/keycloak/keycloak/issues/43294

Declarative tools and GitOps workflows typically keep resource definitions in YAML, which required converting them to JSON before they could be sent to the Admin API v2.

The client endpoints of the Admin API v2 now accept and return YAML in addition to JSON, and the OpenAPI specification describes the new media types.
Use the `application/yaml` media type in the `Content-Type` and `Accept` headers to read, create, and replace clients.
To partially update a client, send a YAML JSON Merge Patch with the `application/merge-patch+yaml` content type.
Requests that do not specify a YAML media type continue to use JSON.

= Security and Standards

== PAR discovery respects the feature flag
Expand Down
50 changes: 49 additions & 1 deletion js/libs/keycloak-admin-client/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,14 @@
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
}
},
"application/yaml" : {
"schema" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
}
}
}
},
Expand All @@ -320,6 +328,11 @@
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
},
"application/yaml" : {
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
}
},
"required" : true
Expand All @@ -332,6 +345,11 @@
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
},
"application/yaml" : {
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
}
}
}
Expand Down Expand Up @@ -360,6 +378,11 @@
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
},
"application/yaml" : {
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
}
}
},
Expand All @@ -379,6 +402,11 @@
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
},
"application/yaml" : {
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
}
},
"required" : true
Expand All @@ -391,6 +419,11 @@
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
},
"application/yaml" : {
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
}
}
},
Expand All @@ -401,14 +434,19 @@
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
},
"application/yaml" : {
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
}
}
}
}
},
"patch" : {
"summary" : "Patch a client",
"description" : "Partially updates a client using JSON Merge Patch",
"description" : "Partially updates a client using JSON Merge Patch (in either JSON or YAML form)",
"operationId" : "patchClient",
"tags" : [ "Clients (v2)" ],
"requestBody" : {
Expand All @@ -417,6 +455,11 @@
"schema" : {
"type" : "object"
}
},
"application/merge-patch+yaml" : {
"schema" : {
"type" : "object"
}
}
},
"required" : true
10BC8 Expand All @@ -429,6 +472,11 @@
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
},
"application/yaml" : {
"schema" : {
"$ref" : "#/components/schemas/BaseClientRepresentation"
}
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,53 @@

package org.keycloak.quarkus.runtime.integration.resteasy;

import java.util.Map;

import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;

import org.jboss.resteasy.reactive.common.util.ServerMediaType;
import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext;
import org.jboss.resteasy.reactive.server.spi.ServerRestHandler;

/**
* <p>A {@link ServerRestHandler} that set the media type produced by a JAX-RS resource method.
*
* <p>The main reason behind this handler is to make the response media type available to {@link org.keycloak.headers.DefaultSecurityHeadersProvider}.
*
* <p>When the method produces multiple media types, the type is negotiated against the Accept header
* using the same logic RESTEasy Reactive applies to {@code @Produces}. If nothing matches, the first produced type is used.
*/
public class SetResponseContentTypeHandler implements ServerRestHandler {

private MediaType producesMediaType;
private final MediaType defaultMediaType;
private final ServerMediaType negotiator;

public SetResponseContentTypeHandler(String[] producesMediaTypes) {
if (producesMediaTypes.length == 0) {
this.producesMediaType = MediaType.APPLICATION_JSON_TYPE;
this.defaultMediaType = MediaType.APPLICATION_JSON_TYPE;
this.negotiator = null;
} else {
this.producesMediaType = MediaType.valueOf(producesMediaTypes[0]);
this.defaultMediaType = MediaType.valueOf(producesMediaTypes[0]);
this.negotiator = producesMediaTypes.length > 1
? new ServerMediaType(ServerMediaType.mediaTypesFromArray(producesMediaTypes), null, false)
: null;
}
}

@Override
public void handle(ResteasyReactiveRequestContext requestContext) {
requestContext.setResponseContentType(producesMediaType);
requestContext.setResponseContentType(select(requestContext));
}

private MediaType select(ResteasyReactiveRequestContext requestContext) {
if (negotiator != null) {
Map.Entry<MediaType, MediaType> negotiated = negotiator
.negotiateProduces(requestContext.getHttpHeaders().getHeaderString(HttpHeaders.ACCEPT));
if (negotiated.getValue() != null) {
return negotiated.getValue();
}
}
return defaultMediaType;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.keycloak.admin.api;

public class PatchTypeNames {
public static final String APPLICATION_YAML = "application/yaml";
public static final String JSON_MERGE = "application/merge-patch+json";
public static final String YAML_MERGE = "application/merge-patch+yaml";
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
public interface ClientApi {

@GET
@Produces(MediaType.APPLICATION_JSON)
@Produces({MediaType.APPLICATION_JSON, PatchTypeNames.APPLICATION_YAML})
@Operation(summary = "Get a client", description = "Returns a single client by its clientId")
@APIResponses(value = {
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class))),
Expand All @@ -36,8 +36,8 @@ public interface ClientApi {
BaseClientRepresentation getClient();

@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Consumes({MediaType.APPLICATION_JSON, PatchTypeNames.APPLICATION_YAML})
@Produces({MediaType.APPLICATION_JSON, PatchTypeNames.APPLICATION_YAML})
@Operation(summary = "Create or update a client", description = "Creates or updates a client in the realm")
@APIResponses(value = {
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class))),
Expand All @@ -57,9 +57,9 @@ default <T extends BaseClientRepresentation> TypedResponse<T> createOrUpdate(T c
}

@PATCH
@Consumes(PatchTypeNames.JSON_MERGE)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Patch a client", description = "Partially updates a client using JSON Merge Patch")
@Consumes({PatchTypeNames.JSON_MERGE, PatchTypeNames.YAML_MERGE})
@Produces({MediaType.APPLICATION_JSON, PatchTypeNames.APPLICATION_YAML})
@Operation(summary = "Patch a client", description = "Partially updates a client using JSON Merge Patch (in either JSON or YAML form)")
@APIResponses(value = {
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class))),
@APIResponse(responseCode = "404", description = "Not Found")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import jakarta.ws.rs.core.Response;

import org.keycloak.admin.api.ListOptions;
import org.keycloak.admin.api.PatchTypeNames;
import org.keycloak.admin.api.TypedResponse;
import org.keycloak.common.constants.KeycloakOpenAPI;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
Expand All @@ -33,8 +34,8 @@
public interface ClientsApi {

@GET
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Produces({MediaType.APPLICATION_JSON, PatchTypeNames.APPLICATION_YAML})
@Consumes({MediaType.APPLICATION_JSON, PatchTypeNames.APPLICATION_YAML})
@Operation(summary = "Get all clients", description = "Returns a list of clients in the realm, optionally filtered by a query expression. "
+ "Results are paginated using offset (0-based, default 0) and limit (default 100).")
@APIResponses(value = {
Expand All @@ -48,8 +49,8 @@ default Stream<BaseClientRepresentation> getClients() {
}

@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Consumes({MediaType.APPLICATION_JSON, PatchTypeNames.APPLICATION_YAML})
@Produces({MediaType.APPLICATION_JSON, PatchTypeNames.APPLICATION_YAML})
@Operation(summary = "Create a new client", description = "Creates a new client in the realm")
@APIResponses(value = {
@APIResponse(responseCode = "201", description = "Created", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class)))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.keycloak.rest.admin.api;

import java.util.stream.Stream;

import jakarta.ws.rs.Produces;
import jakarta.ws.rs.ext.ContextResolver;
import jakarta.ws.rs.ext.Provider;

import org.keycloak.admin.api.PatchTypeNames;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.type.TypeFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import com.fasterxml.jackson.datatype.jdk8.StreamSerializer;

/**
* Provides the {@link YAMLMapper} used to serialize and deserialize {@code application/yaml} payloads.
* It is configured like the JSON mapper, for example to support {@link Stream} results and omit null values.
*/
@Provider
@Produces({PatchTypeNames.APPLICATION_YAML, PatchTypeNames.YAML_MERGE})
public class YamlMapperResolver implements ContextResolver<YAMLMapper> {

private static final YAMLMapper MAPPER = createMapper();

private static YAMLMapper createMapper() {
YAMLMapper mapper = new YAMLMapper();
JavaType type = TypeFactory.unknownType();
JavaType streamType = mapper.getTypeFactory().constructParametricType(Stream.class, type);

SimpleModule module = new SimpleModule();
module.addSerializer(new StreamSerializer(streamType, type));
mapper.registerModule(module);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.findAndRegisterModules();
return mapper;
}

@Override
public YAMLMapper getContext(Class<?> type) {
return MAPPER;
}
}
Loading
Loading
0