OpenApiTransformer.java
/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
* Portions Copyright 2018-2026 Wren Security.
*/
package org.forgerock.api.transform;
import static java.lang.Boolean.TRUE;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static java.util.Collections.unmodifiableList;
import static org.forgerock.api.markup.asciidoc.AsciiDoc.normalizeName;
import static org.forgerock.api.util.PathUtil.buildPath;
import static org.forgerock.api.util.PathUtil.buildPathParameters;
import static org.forgerock.api.util.PathUtil.mergeParameters;
import static org.forgerock.api.util.ValidationUtil.isEmpty;
import static org.forgerock.json.JsonValue.array;
import static org.forgerock.json.JsonValue.field;
import static org.forgerock.json.JsonValue.fieldIfNotNull;
import static org.forgerock.json.JsonValue.json;
import static org.forgerock.json.JsonValue.object;
import static org.forgerock.json.JsonValueFunctions.listOf;
import static org.forgerock.json.schema.validator.Constants.DEFAULT;
import static org.forgerock.json.schema.validator.Constants.DESCRIPTION;
import static org.forgerock.json.schema.validator.Constants.ENUM;
import static org.forgerock.json.schema.validator.Constants.ID;
import static org.forgerock.json.schema.validator.Constants.ITEMS;
import static org.forgerock.json.schema.validator.Constants.PROPERTIES;
import static org.forgerock.json.schema.validator.Constants.REQUIRED;
import static org.forgerock.json.schema.validator.Constants.TITLE;
import static org.forgerock.json.schema.validator.Constants.TYPE;
import static org.forgerock.json.schema.validator.Constants.TYPE_ARRAY;
import static org.forgerock.json.schema.validator.Constants.TYPE_INTEGER;
import static org.forgerock.json.schema.validator.Constants.TYPE_NULL;
import static org.forgerock.json.schema.validator.Constants.TYPE_OBJECT;
import static org.forgerock.json.schema.validator.Constants.TYPE_STRING;
import static org.forgerock.util.Reject.checkNotNull;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.headers.Header;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.media.Content;
import io.swagger.v3.oas.models.media.MediaType;
import io.swagger.v3.oas.models.responses.ApiResponse;
import io.swagger.v3.oas.models.responses.ApiResponses;
import io.swagger.v3.oas.models.servers.Server;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.forgerock.api.enums.CountPolicy;
import org.forgerock.api.enums.PagingMode;
import org.forgerock.api.enums.PatchOperation;
import org.forgerock.api.enums.QueryType;
import org.forgerock.api.enums.Stability;
import org.forgerock.api.markup.asciidoc.AsciiDoc;
import org.forgerock.api.models.Action;
import org.forgerock.api.models.ApiDescription;
import org.forgerock.api.models.ApiError;
import org.forgerock.api.models.Create;
import org.forgerock.api.models.Definitions;
import org.forgerock.api.models.Delete;
import org.forgerock.api.models.Items;
import org.forgerock.api.models.Parameter;
import org.forgerock.api.models.Patch;
import org.forgerock.api.models.Paths;
import org.forgerock.api.models.Query;
import org.forgerock.api.models.Read;
import org.forgerock.api.models.Reference;
import org.forgerock.api.models.Resource;
import org.forgerock.api.models.Schema;
import org.forgerock.api.models.SubResources;
import org.forgerock.api.models.Update;
import org.forgerock.api.models.VersionedPath;
import org.forgerock.api.util.BigDecimalUtil;
import org.forgerock.api.util.PathUtil;
import org.forgerock.api.util.ReferenceResolver;
import org.forgerock.api.util.ValidationUtil;
import org.forgerock.http.header.AcceptApiVersionHeader;
import org.forgerock.http.routing.Version;
import org.forgerock.http.swagger.SwaggerExtended;
import org.forgerock.json.JsonValue;
import org.forgerock.json.JsonValueException;
import org.forgerock.util.Function;
import org.forgerock.util.annotations.VisibleForTesting;
import org.forgerock.util.i18n.LocalizableString;
import org.forgerock.util.i18n.PreferredLocales;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.wrensecurity.guava.common.base.Joiner;
import org.wrensecurity.guava.common.hash.Hashing;
import org.wrensecurity.guava.common.io.BaseEncoding;
/**
* Transforms an {@link ApiDescription} into an OpenAPI 3.0 model.
*
* @see <a href="https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md">OpenAPI 3.0</a> spec
*/
public class OpenApiTransformer {
private static final Logger logger = LoggerFactory.getLogger(OpenApiTransformer.class);
private static final String EMPTY_STRING = "";
private static final String PARAMETER_FIELDS = "_fields";
private static final String PARAMETER_PRETTY_PRINT = "_prettyPrint";
private static final String PARAMETER_MIME_TYPE = "_mimeType";
private static final String PARAMETER_IF_MATCH = "If-Match";
private static final String PARAMETER_IF_NONE_MATCH = "If-None-Match";
private static final String PARAMETER_IF_NONE_MATCH_ANY_ONLY = "If-None-Match: *";
private static final String PARAMETER_IF_NONE_MATCH_REV_ONLY = "If-None-Match: <rev>";
private static final String PARAMETER_LOCATION = "Location";
/** JSON Reference prefix for component schemas (replaces v2 #/definitions/). */
private static final String SCHEMAS_REF_PREFIX = "#/components/schemas/";
/**
* Prefix for JSON Schema references prefixed with {@code urn:jsonschema:}.
* <p/>
* Note that, by default, Jackson uses this scheme in
* {@link com.fasterxml.jackson.module.jsonSchema.factories.VisitorContext}.
*/
static final String URN_JSONSCHEMA_PREFIX = "urn:jsonschema:";
/** Prefix for ForgeRock API JSON Schema references, prefixed with {@code frapi:}. */
static final String FRAPI_PREFIX = "frapi:";
private static final String I18N_PREFIX = LocalizableString.TRANSLATION_KEY_PREFIX + "ApiDescription#";
private static final String FIELDS_PARAMETER_DESCRIPTION = I18N_PREFIX + "common.parameters.fields";
private static final String PRETTYPRINT_PARAMETER_DESCRIPTION = I18N_PREFIX + "common.parameters.prettyprint";
private static final String MIMETYPE_PARAMETER_DESCRIPTION = I18N_PREFIX + "common.parameters.mimetype";
private static final String LOCATION_PARAMETER_DESCRIPTION = I18N_PREFIX + "common.parameters.location";
@VisibleForTesting
final OpenAPI openApi;
private final ReferenceResolver referenceResolver;
private final ApiDescription apiDescription;
@SuppressWarnings("rawtypes")
private final Map<String, io.swagger.v3.oas.models.media.Schema> definitionMap = new HashMap<>();
/** {@code Location}-header description. */
private final LocalizableString locationDescription =
new LocalizableString(LOCATION_PARAMETER_DESCRIPTION, getClass().getClassLoader());
/** Default constructor that is only used by unit tests. */
@VisibleForTesting
OpenApiTransformer() {
openApi = null;
referenceResolver = null;
apiDescription = null;
}
/**
* Constructor.
*
* @param title API title
* @param host Hostname or IP address, with optional port
* @param basePath Base-path on host
* @param secure {@code true} when host is using HTTPS and {@code false} when using HTTP
* @param apiDescription CREST API Descriptor
* @param externalApiDescriptions External CREST API Descriptions, for resolving {@link Reference}s, or {@code null}
*/
@VisibleForTesting
OpenApiTransformer(final LocalizableString title, final String host, final String basePath, final boolean secure,
final ApiDescription apiDescription, final ApiDescription... externalApiDescriptions) {
this.apiDescription = checkNotNull(apiDescription, "apiDescription required");
openApi = new SwaggerExtended();
openApi.setComponents(new Components());
openApi.setPaths(new io.swagger.v3.oas.models.Paths());
openApi.info(buildInfo(title));
// Build server URL from host/basePath/scheme
if (!isEmpty(host)) {
String scheme = secure ? "https" : "http";
String serverUrl = scheme + "://" + host;
if (!isEmpty(basePath)) {
serverUrl += PathUtil.buildPath(basePath);
}
openApi.addServersItem(new Server().url(serverUrl));
} else if (!isEmpty(basePath)) {
openApi.addServersItem(new Server().url(PathUtil.buildPath(basePath)));
}
referenceResolver = new ReferenceResolver(apiDescription);
if (externalApiDescriptions != null) {
referenceResolver.registerAll(externalApiDescriptions);
}
}
/**
* Transforms an {@link ApiDescription} into an {@code OpenAPI} model.
*
* @param title API title
* @param host Hostname or IP address, with optional port
* @param basePath Base-path on host
* @param secure {@code true} when host is using HTTPS and {@code false} when using HTTP
* @param apiDescription CREST API Descriptor
* @param externalApiDescriptions External CREST API Descriptions, for resolving {@link Reference}s, or {@code null}
* @return {@code OpenAPI} model
*/
public static OpenAPI execute(final LocalizableString title, final String host, final String basePath,
final boolean secure, final ApiDescription apiDescription,
final ApiDescription... externalApiDescriptions) {
final OpenApiTransformer transformer = new OpenApiTransformer(title, host, basePath, secure, apiDescription,
externalApiDescriptions);
return transformer.doExecute();
}
/**
* Transforms an {@link ApiDescription} into an {@code OpenAPI} model.
* <p>
* Note: The returned descriptor does not contain an {@code Info} object, a server, as
* these will all depend on the deployment and/or request.
* </p>
*
* @param apiDescription CREST API Descriptor
* @param externalApiDescriptions External CREST API Descriptions, for resolving {@link Reference}s, or {@code null}
* @return {@code OpenAPI} model
*/
public static OpenAPI execute(ApiDescription apiDescription, ApiDescription... externalApiDescriptions) {
final OpenApiTransformer transformer = new OpenApiTransformer(null, null, null, false, apiDescription,
externalApiDescriptions);
return transformer.doExecute();
}
/**
* Do the work to transform an {@link ApiDescription} into an {@code OpenAPI} model.
*
* @return {@code OpenAPI} model
*/
private OpenAPI doExecute() {
buildParameters();
buildPaths();
buildDefinitions();
return openApi;
}
/** Build globally-defined parameters, which are referred to by-reference. */
private void buildParameters() {
ClassLoader loader = getClass().getClassLoader();
// _fields
final LocalizableQueryParameter fieldsParameter = new LocalizableQueryParameter();
fieldsParameter.setName(PARAMETER_FIELDS);
fieldsParameter.setType("string");
fieldsParameter.setCollectionFormat("csv");
fieldsParameter.description(new LocalizableString(FIELDS_PARAMETER_DESCRIPTION, loader));
openApi.getComponents().addParameters(fieldsParameter.getName(), fieldsParameter);
// _prettyPrint
final LocalizableQueryParameter prettyPrintParameter = new LocalizableQueryParameter();
prettyPrintParameter.setName(PARAMETER_PRETTY_PRINT);
prettyPrintParameter.setType("boolean");
prettyPrintParameter.description(new LocalizableString(PRETTYPRINT_PARAMETER_DESCRIPTION, loader));
openApi.getComponents().addParameters(prettyPrintParameter.getName(), prettyPrintParameter);
// _mimeType
final LocalizableQueryParameter mimeTypeParameter = new LocalizableQueryParameter();
mimeTypeParameter.setName(PARAMETER_MIME_TYPE);
mimeTypeParameter.setType("string");
mimeTypeParameter.description(new LocalizableString(MIMETYPE_PARAMETER_DESCRIPTION, loader));
openApi.getComponents().addParameters(mimeTypeParameter.getName(), mimeTypeParameter);
// PUT-operation IF-NONE-MATCH always has * value
final LocalizableHeaderParameter putIfNoneMatchParameter = new LocalizableHeaderParameter();
putIfNoneMatchParameter.setName(PARAMETER_IF_NONE_MATCH);
putIfNoneMatchParameter.setType("string");
putIfNoneMatchParameter.setRequired(true);
putIfNoneMatchParameter.setEnum(asList("*"));
openApi.getComponents().addParameters(PARAMETER_IF_NONE_MATCH_ANY_ONLY, putIfNoneMatchParameter);
// READ-operation IF-NONE-MATCH cannot have * value
final LocalizableHeaderParameter readIfNoneMatchParameter = new LocalizableHeaderParameter();
readIfNoneMatchParameter.setName(PARAMETER_IF_NONE_MATCH);
readIfNoneMatchParameter.setType("string");
openApi.getComponents().addParameters(PARAMETER_IF_NONE_MATCH_REV_ONLY, readIfNoneMatchParameter);
// IF-MATCH
final LocalizableHeaderParameter ifMatchParameter = new LocalizableHeaderParameter();
ifMatchParameter.setName(PARAMETER_IF_MATCH);
ifMatchParameter.setType("string");
ifMatchParameter.setDefault("*");
openApi.getComponents().addParameters(ifMatchParameter.getName(), ifMatchParameter);
}
/** Traverse CREST API Descriptor paths, to build the OpenAPI model. */
private void buildPaths() {
final Paths paths = apiDescription.getPaths();
if (paths != null) {
final Map<String, PathItem> pathMap = new LinkedHashMap<>();
final List<String> pathNames = new ArrayList<>(paths.getNames());
Collections.sort(pathNames);
for (final String pathName : pathNames) {
final VersionedPath versionedPath = paths.get(pathName);
final List<Version> versions = new ArrayList<>(versionedPath.getVersions());
Collections.sort(versions);
for (final Version version : versions) {
final String versionName;
if (VersionedPath.UNVERSIONED.equals(version)) {
versionName = EMPTY_STRING;
} else {
versionName = version.toString();
}
final Resource resource = resolveResourceReference(versionedPath.get(version));
final String normalizedPathName = pathName.isEmpty() ? "/" : PathUtil.buildPath(pathName);
buildResourcePaths(resource, normalizedPathName, null, versionName,
Collections.<Parameter>emptyList(), pathMap);
}
}
io.swagger.v3.oas.models.Paths openApiPaths = new io.swagger.v3.oas.models.Paths();
openApiPaths.putAll(pathMap);
openApi.setPaths(openApiPaths);
}
}
private Resource resolveResourceReference(Resource resource) {
Reference resourceReference = resource.getReference();
if (resourceReference != null) {
resource = referenceResolver.getService(resourceReference);
if (resource == null) {
throw new TransformerException("Unresolvable reference: " + resourceReference.getValue());
}
}
return resource;
}
/**
* Constructs paths, for a given resource, and works with OpenAPI's current inability to overload paths for a
* given REST operation (e.g., multiple {@code get} operations) by adding a URL-fragment {@code #} suffix
* to the end of the path.
*
* @param resource CREST resource
* @param pathName Resource path-name
* @param parentTag Tag for grouping operations together by resource/version or {@code null} if there is no parent
* @param resourceVersion Resource version-name or empty-string
* @param parameters CREST operation parameters
* @param pathMap Output for OpenAPI paths that are constructed
*/
private void buildResourcePaths(final Resource resource, final String pathName, final LocalizableString parentTag,
final String resourceVersion, final List<Parameter> parameters, final Map<String, PathItem> pathMap) {
final boolean hasResourceVersion = !isEmpty(resourceVersion);
final String pathNamespace = hasResourceVersion
? normalizeName(pathName, resourceVersion) : normalizeName(pathName);
LocalizableString tag = parentTag;
if (tag == null || isEmpty(tag.toString())) {
final LocalizableString title = resource.getTitle();
final String titleString = title.toString();
tag = new LocalizableString(hasResourceVersion ? titleString + " v" + resourceVersion : titleString) {
@Override
public String toTranslatedString(PreferredLocales locales) {
String tag = !isEmpty(titleString)
? title.toTranslatedString(locales)
: pathName;
if (hasResourceVersion) {
tag += " v" + resourceVersion;
}
return tag;
}
};
openApi.addTagsItem(new LocalizableTag().name(tag));
}
Schema resourceSchema = null;
if (resource.getResourceSchema() != null) {
resourceSchema = resource.getResourceSchema();
}
final List<Parameter> operationParameters = unmodifiableList(
mergeParameters(new ArrayList<>(parameters), resource.getParameters()));
buildCreate(resource, pathName, pathNamespace, tag, resourceVersion, resourceSchema,
operationParameters, pathMap);
buildRead(resource, pathName, pathNamespace, tag, resourceVersion, resourceSchema,
operationParameters, pathMap);
buildUpdate(resource, pathName, pathNamespace, tag, resourceVersion, resourceSchema,
operationParameters, pathMap);
buildDelete(resource, pathName, pathNamespace, tag, resourceVersion, resourceSchema,
operationParameters, pathMap);
buildPatch(resource, pathName, pathNamespace, tag, resourceVersion, resourceSchema,
operationParameters, pathMap);
buildActions(resource, pathName, pathNamespace, tag, resourceVersion,
operationParameters, pathMap);
buildQueries(resource, pathName, pathNamespace, tag, resourceVersion, resourceSchema,
operationParameters, pathMap);
buildItems(resource, pathName, tag, resourceVersion, parameters, pathMap);
buildSubResources(resource.getSubresources(), pathName, resourceVersion, parameters, pathMap);
}
private void buildItems(final Resource resource, final String pathName, final LocalizableString parentTag,
final String resourceVersion, final List<Parameter> parameters, final Map<String, PathItem> pathMap) {
if (resource.getItems() != null) {
final Items items = resource.getItems();
final Resource itemsResource = items.asResource(resource.isMvccSupported(),
resource.getResourceSchema(), resource.getTitle(), resource.getDescription());
final Parameter pathParameter = items.getPathParameter();
final List<Parameter> itemsParameters = unmodifiableList(mergeParameters(mergeParameters(
new ArrayList<>(parameters), resource.getParameters()), pathParameter));
final String itemsPath = buildPath(pathName, "/{" + pathParameter.getName() + "}");
buildSubResources(items.getSubresources(), itemsPath, resourceVersion, itemsParameters, pathMap);
buildResourcePaths(itemsResource, itemsPath, parentTag, resourceVersion,
itemsParameters, pathMap);
}
}
private void buildSubResources(final SubResources subResources, final String pathName,
final String resourceVersion, final List<Parameter> parameters, final Map<String, PathItem> pathMap) {
if (subResources != null) {
final List<String> subPathNames = new ArrayList<>(subResources.getNames());
Collections.sort(subPathNames);
for (final String name : subPathNames) {
final List<Parameter> subresourcesParameters = mergeParameters(new ArrayList<>(parameters),
buildPathParameters(name));
final String subPathName = buildPath(pathName, name);
Resource subResource = resolveResourceReference(subResources.get(name));
buildResourcePaths(subResource, subPathName, null, resourceVersion,
unmodifiableList(subresourcesParameters), pathMap);
}
}
}
private void buildCreate(final Resource resource, final String pathName, final String pathNamespace,
final LocalizableString tag, final String resourceVersion, final Schema resourceSchema,
final List<Parameter> parameters, final Map<String, PathItem> pathMap) {
if (resource.getCreate() != null) {
final Create create = resource.getCreate();
switch (create.getMode()) {
case ID_FROM_CLIENT:
final String createPutNamespace = normalizeName(pathNamespace, "create", "put");
final String createPutPathFragment = normalizeName(resourceVersion, "create", "put");
final LocalizableOperation putOperation = buildOperation(create, createPutNamespace, resourceSchema,
resourceSchema, parameters);
putOperation.setSummary("Create with Client-Assigned ID");
if (resource.isMvccSupported()) {
putOperation.addParametersItem(
new io.swagger.v3.oas.models.parameters.Parameter()
.$ref("#/components/parameters/" + PARAMETER_IF_NONE_MATCH_ANY_ONLY));
}
addOperation(putOperation, "put", pathName, createPutPathFragment, resourceVersion, tag, pathMap);
break;
case ID_FROM_SERVER:
final String createPostNamespace = normalizeName(pathNamespace, "create", "post");
final String createPostPathFragment = normalizeName(resourceVersion, "create", "post");
final LocalizableOperation postOperation = buildOperation(create, createPostNamespace, resourceSchema,
resourceSchema, parameters);
postOperation.setSummary("Create with Server-Assigned ID");
addOperation(postOperation, "post", pathName, createPostPathFragment, resourceVersion, tag,
pathMap);
break;
default:
throw new TransformerException("Unsupported CreateMode: " + create.getMode());
}
}
}
private void buildRead(final Resource resource, final String pathName, final String pathNamespace,
final LocalizableString tag, final String resourceVersion, final Schema resourceSchema,
final List<Parameter> parameters, final Map<String, PathItem> pathMap) {
if (resource.getRead() != null) {
final String operationNamespace = normalizeName(pathNamespace, "read");
final String operationPathFragment = normalizeName(resourceVersion, "read");
final Read read = resource.getRead();
final LocalizableOperation operation = buildOperation(read, operationNamespace, null, resourceSchema,
parameters);
operation.setSummary("Read");
operation.addParametersItem(
new io.swagger.v3.oas.models.parameters.Parameter()
.$ref("#/components/parameters/" + PARAMETER_MIME_TYPE));
if (resource.isMvccSupported()) {
operation.addParametersItem(
new io.swagger.v3.oas.models.parameters.Parameter()
.$ref("#/components/parameters/" + PARAMETER_IF_NONE_MATCH_REV_ONLY));
}
addOperation(operation, "get", pathName, operationPathFragment, resourceVersion, tag, pathMap);
}
}
private void buildUpdate(final Resource resource, final String pathName, final String pathNamespace,
final LocalizableString tag, final String resourceVersion, final Schema resourceSchema,
final List<Parameter> parameters, final Map<String, PathItem> pathMap) {
if (resource.getUpdate() != null) {
final String operationNamespace = normalizeName(pathNamespace, "update");
final String operationPathFragment = normalizeName(resourceVersion, "update");
final Update update = resource.getUpdate();
final LocalizableOperation operation = buildOperation(update, operationNamespace, resourceSchema,
resourceSchema, parameters);
operation.setSummary("Update");
if (resource.isMvccSupported()) {
operation.addParametersItem(
new io.swagger.v3.oas.models.parameters.Parameter()
.$ref("#/components/parameters/" + PARAMETER_IF_MATCH));
}
addOperation(operation, "put", pathName, operationPathFragment, resourceVersion, tag, pathMap);
}
}
private void buildDelete(final Resource resource, final String pathName, final String pathNamespace,
final LocalizableString tag, final String resourceVersion, final Schema resourceSchema,
final List<Parameter> parameters, final Map<String, PathItem> pathMap) {
if (resource.getDelete() != null) {
final String operationNamespace = normalizeName(pathNamespace, "delete");
final String operationPathFragment = normalizeName(resourceVersion, "delete");
final Delete delete = resource.getDelete();
final LocalizableOperation operation = buildOperation(delete, operationNamespace, null, resourceSchema,
parameters);
operation.setSummary("Delete");
if (resource.isMvccSupported()) {
operation.addParametersItem(
new io.swagger.v3.oas.models.parameters.Parameter()
.$ref("#/components/parameters/" + PARAMETER_IF_MATCH));
}
addOperation(operation, "delete", pathName, operationPathFragment, resourceVersion, tag, pathMap);
}
}
private void buildPatch(final Resource resource, final String pathName, final String pathNamespace,
final LocalizableString tag, final String resourceVersion, final Schema resourceSchema,
final List<Parameter> parameters, final Map<String, PathItem> pathMap) {
if (resource.getPatch() != null) {
final String operationNamespace = normalizeName(pathNamespace, "patch");
final String operationPathFragment = normalizeName(resourceVersion, "patch");
final Patch patch = resource.getPatch();
final Schema requestSchema = buildPatchRequestPayload(patch.getOperations());
final LocalizableOperation operation = buildOperation(patch, operationNamespace, requestSchema,
resourceSchema, parameters);
operation.setSummary("Update via Patch Operations");
if (resource.isMvccSupported()) {
operation.addParametersItem(
new io.swagger.v3.oas.models.parameters.Parameter()
.$ref("#/components/parameters/" + PARAMETER_IF_MATCH));
}
addOperation(operation, "patch", pathName, operationPathFragment, resourceVersion, tag, pathMap);
}
}
private void buildActions(final Resource resource, final String pathName, final String pathNamespace,
final LocalizableString tag, final String resourceVersion, final List<Parameter> parameters,
final Map<String, PathItem> pathMap) {
if (!isEmpty(resource.getActions())) {
final String operationNamespace = normalizeName(pathNamespace, "action");
final String operationPathFragment = normalizeName(resourceVersion, "action");
for (final Action action : resource.getActions()) {
final String actionNamespace = normalizeName(operationNamespace, action.getName());
final String actionPathFragment = normalizeName(operationPathFragment, action.getName());
final LocalizableOperation operation = buildOperation(action, actionNamespace, action.getRequest(),
action.getResponse(), parameters);
operation.setSummary("Action: " + action.getName());
final LocalizableQueryParameter actionParameter = new LocalizableQueryParameter();
actionParameter.setName("_action");
actionParameter.setType("string");
actionParameter.setEnum(asList(action.getName()));
actionParameter.setRequired(true);
operation.addParametersItem(actionParameter);
addOperation(operation, "post", pathName, actionPathFragment, resourceVersion, tag, pathMap);
}
}
}
private void buildQueries(final Resource resource, final String pathName, final String pathNamespace,
final LocalizableString tag, final String resourceVersion, final Schema resourceSchema,
final List<Parameter> parameters, final Map<String, PathItem> pathMap) {
if (!isEmpty(resource.getQueries())) {
final String operationNamespace = normalizeName(pathNamespace, "query");
final String operationPathFragment = normalizeName(resourceVersion, "query");
for (final Query query : resource.getQueries()) {
final String queryNamespace;
final String queryPathFragment;
final String summary;
final LocalizableQueryParameter queryParameter;
switch (query.getType()) {
case ID:
queryNamespace = normalizeName(operationNamespace, "id", query.getQueryId());
queryPathFragment = normalizeName(operationPathFragment, "id", query.getQueryId());
summary = "Query by ID: " + query.getQueryId();
queryParameter = new LocalizableQueryParameter();
queryParameter.setName("_queryId");
queryParameter.setType("string");
queryParameter.setEnum(asList(query.getQueryId()));
queryParameter.setRequired(true);
break;
case FILTER:
queryNamespace = normalizeName(operationNamespace, "filter");
queryPathFragment = normalizeName(operationPathFragment, "filter");
summary = "Query by Filter";
queryParameter = new LocalizableQueryParameter();
queryParameter.setName("_queryFilter");
queryParameter.setType("string");
queryParameter.setRequired(true);
break;
case EXPRESSION:
queryNamespace = normalizeName(operationNamespace, "expression");
queryPathFragment = normalizeName(operationPathFragment, "expression");
summary = "Query by Expression";
queryParameter = new LocalizableQueryParameter();
queryParameter.setName("_queryExpression");
queryParameter.setType("string");
queryParameter.setRequired(true);
break;
default:
throw new TransformerException("Unsupported QueryType: " + query.getType());
}
final Schema responsePayload;
if (resourceSchema.getSchema() != null
&& !"array".equals(getType(resourceSchema.getSchema()))) {
responsePayload = Schema.schema().schema(
json(object(
field(TYPE, TYPE_OBJECT),
field(TITLE, localizable("common.query.title")),
field(PROPERTIES, object(
field("result", object(
field(TYPE, TYPE_ARRAY),
field(DESCRIPTION, localizable(
"common.query.properties.result")),
field(ITEMS, resourceSchema.getSchema()))),
field("resultCount", object(
field(TYPE, TYPE_INTEGER),
field(DESCRIPTION, localizable(
"common.query.properties.resultCount")),
field(DEFAULT, "0"))),
field("pagedResultsCookie", object(
field(TYPE, array(TYPE_NULL, TYPE_STRING)),
field(DESCRIPTION, localizable(
"common.query.properties.pagedResultsCookie")))),
field("totalPagedResultsPolicy", object(
field(TYPE, TYPE_STRING),
field(DESCRIPTION, localizable(
"common.query.properties.totalPagedResultsPolicy")),
field(DEFAULT, "NONE"))),
field("totalPagedResults", object(
field(TYPE, TYPE_INTEGER),
field(DESCRIPTION, localizable(
"common.query.properties.totalPagedResults")),
field("default", "-1"))),
field("remainingPagedResults", object(
field(TYPE, TYPE_INTEGER),
field(DESCRIPTION, localizable(
"common.query.properties.remainingPagedResults")),
field(DEFAULT, "-1"))))))))
.build();
} else {
responsePayload = resourceSchema;
}
final LocalizableOperation operation = buildOperation(query, queryNamespace, null, responsePayload,
parameters);
operation.setSummary(summary);
operation.addParametersItem(queryParameter);
final LocalizableQueryParameter pageSizeParamter = new LocalizableQueryParameter();
pageSizeParamter.setName("_pageSize");
pageSizeParamter.setType("integer");
operation.addParametersItem(pageSizeParamter);
if (query.getPagingModes() != null) {
for (final PagingMode pagingMode : query.getPagingModes()) {
final LocalizableQueryParameter parameter = new LocalizableQueryParameter();
switch (pagingMode) {
case COOKIE:
parameter.setName("_pagedResultsCookie");
parameter.setType("string");
break;
case OFFSET:
parameter.setName("_pagedResultsOffset");
parameter.setType("integer");
break;
default:
throw new TransformerException("Unsupported PagingMode: " + pagingMode);
}
operation.addParametersItem(parameter);
}
}
final LocalizableQueryParameter totalPagedResultsPolicyParameter = new LocalizableQueryParameter();
totalPagedResultsPolicyParameter.setName("_totalPagedResultsPolicy");
totalPagedResultsPolicyParameter.setType("string");
final List<String> totalPagedResultsPolicyValues = new ArrayList<>();
if (query.getCountPolicies() == null || query.getCountPolicies().length == 0) {
totalPagedResultsPolicyValues.add(CountPolicy.NONE.name());
} else {
for (final CountPolicy countPolicy : query.getCountPolicies()) {
totalPagedResultsPolicyValues.add(countPolicy.name());
}
}
totalPagedResultsPolicyParameter.setEnum(totalPagedResultsPolicyValues);
operation.addParametersItem(totalPagedResultsPolicyParameter);
if (query.getType() != QueryType.ID) {
final LocalizableQueryParameter sortKeysParameter = new LocalizableQueryParameter();
sortKeysParameter.setName("_sortKeys");
sortKeysParameter.setType("string");
if (!isEmpty(query.getSupportedSortKeys())) {
sortKeysParameter.setEnum(asList(query.getSupportedSortKeys()));
}
operation.addParametersItem(sortKeysParameter);
}
addOperation(operation, "get", pathName, queryPathFragment, resourceVersion, tag, pathMap);
}
}
}
private static LocalizableString localizable(String value) {
return new LocalizableString(I18N_PREFIX + value, OpenApiTransformer.class.getClassLoader());
}
private LocalizableOperation buildOperation(final org.forgerock.api.models.Operation operationModel,
final String operationNamespace, final Schema requestPayload, final Schema responsePayload,
final List<Parameter> parameters) {
final LocalizableOperation operation = new LocalizableOperation();
operation.setOperationId(operationNamespace);
operation.description(operationModel.getDescription());
applyOperationStability(operationModel.getStability(), operation);
applyOperationParameters(mergeParameters(new ArrayList<>(parameters), operationModel.getParameters()),
operation);
applyOperationRequestPayload(requestPayload, operation);
applyOperationResponsePayloads(responsePayload, operationModel.getApiErrors(), operationModel, operation);
return operation;
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private void addOperation(final LocalizableOperation operation, final String method, final String pathName,
final String pathFragment, final String resourceVersion, final LocalizableString tag,
final Map<String, PathItem> pathMap) {
boolean showPathFragment = false;
if (!isEmpty(resourceVersion)) {
showPathFragment = true;
operation.setVendorExtension("x-resourceVersion", resourceVersion);
io.swagger.v3.oas.models.parameters.Parameter versionHeader =
new io.swagger.v3.oas.models.parameters.Parameter()
.in("header")
.name(AcceptApiVersionHeader.NAME)
.required(true)
.schema(new io.swagger.v3.oas.models.media.Schema<String>()
.type("string")
._enum(singletonList(AcceptApiVersionHeader.RESOURCE + "=" + resourceVersion)));
operation.addParametersItem(versionHeader);
}
if (!isEmpty(tag.toString())) {
operation.addTag(tag);
}
PathItem operationPath = pathMap.get(pathName);
if (operationPath == null) {
operationPath = new PathItem();
} else if (!showPathFragment) {
switch (method) {
case "get":
showPathFragment = operationPath.getGet() != null;
break;
case "post":
showPathFragment = operationPath.getPost() != null;
break;
case "put":
showPathFragment = operationPath.getPut() != null;
break;
case "delete":
showPathFragment = operationPath.getDelete() != null;
break;
case "patch":
showPathFragment = operationPath.getPatch() != null;
break;
default:
throw new TransformerException("Unsupported method: " + method);
}
}
if (showPathFragment) {
if (pathName.indexOf('#') != -1) {
throw new TransformerException("pathName cannot contain # character");
}
final String uniquePath = pathName + '#' + pathFragment;
if (pathMap.containsKey(uniquePath)) {
throw new TransformerException("pathFragment is not unique for given pathName");
}
operationPath = new PathItem();
pathMap.put(uniquePath, operationPath);
} else {
pathMap.put(pathName, operationPath);
}
if (!setOperationOnPathItem(operationPath, method, operation)) {
throw new TransformerException("Unsupported method: " + method);
}
}
private boolean setOperationOnPathItem(PathItem pathItem, String method, Operation operation) {
switch (method) {
case "get":
pathItem.setGet(operation);
return true;
case "post":
pathItem.setPost(operation);
return true;
case "put":
pathItem.setPut(operation);
return true;
case "delete":
pathItem.setDelete(operation);
return true;
case "patch":
pathItem.setPatch(operation);
return true;
default:
return false;
}
}
private void applyOperationStability(final Stability stability, final Operation operation) {
if (stability == Stability.DEPRECATED || stability == Stability.REMOVED) {
operation.setDeprecated(TRUE);
}
}
private void applyOperationParameters(final List<Parameter> parameters, final Operation operation) {
if (!parameters.isEmpty()) {
for (final Parameter parameter : parameters) {
final LocalizableParameter operationParameter;
switch (parameter.getSource()) {
case PATH:
operationParameter = new LocalizablePathParameter();
break;
case ADDITIONAL:
operationParameter = new LocalizableQueryParameter();
break;
default:
throw new TransformerException("Unsupported ParameterSource: " + parameter.getSource());
}
operationParameter.setName(parameter.getName());
operationParameter.setType(parameter.getType());
operationParameter.description(parameter.getDescription());
operationParameter.setRequired(ValidationUtil.nullToFalse(parameter.isRequired()));
if (!isEmpty(parameter.getEnumValues())) {
operationParameter.setEnum(asList(parameter.getEnumValues()));
if (!isEmpty(parameter.getEnumTitles())) {
operationParameter.addExtension("x-enum_titles",
asList(parameter.getEnumTitles()));
}
}
operation.addParametersItem(operationParameter);
}
}
// apply common parameters
operation.addParametersItem(
new io.swagger.v3.oas.models.parameters.Parameter()
.$ref("#/components/parameters/" + PARAMETER_FIELDS));
operation.addParametersItem(
new io.swagger.v3.oas.models.parameters.Parameter()
.$ref("#/components/parameters/" + PARAMETER_PRETTY_PRINT));
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private void applyOperationRequestPayload(final Schema schema, final Operation operation) {
if (schema != null) {
final io.swagger.v3.oas.models.media.Schema mediaSchema;
if (schema.getSchema() != null) {
if (hasReferenceableId(schema.getSchema())) {
final String name = addDefinitionReference(schema.getSchema(), buildSchema(schema.getSchema()));
mediaSchema = new io.swagger.v3.oas.models.media.Schema().$ref(SCHEMAS_REF_PREFIX + name);
} else {
mediaSchema = buildSchema(schema.getSchema());
}
} else {
final String ref = getDefinitionsReference(schema.getReference());
if (ref == null) {
throw new TransformerException("Invalid JSON ref");
}
mediaSchema = new io.swagger.v3.oas.models.media.Schema().$ref(SCHEMAS_REF_PREFIX + ref);
}
final LocalizableRequestBody requestBody = new LocalizableRequestBody();
requestBody.setRequired(true);
requestBody.setContent(new Content().addMediaType("application/json",
new MediaType().schema(mediaSchema)));
operation.setRequestBody(requestBody);
}
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private void applyOperationResponsePayloads(final Schema schema, final ApiError[] apiErrorResponses,
final org.forgerock.api.models.Operation operationModel, final Operation operation) {
final ApiResponses responses = new ApiResponses();
if (schema != null) {
final ApiResponse response = new ApiResponse();
response.description("Success");
io.swagger.v3.oas.models.media.Schema responseSchema;
if (schema.getSchema() != null) {
final io.swagger.v3.oas.models.media.Schema builtSchema = buildSchema(schema.getSchema());
String name = addDefinitionReference(schema.getSchema(), builtSchema);
if (name == null) {
name = "urn:uuid:" + UUID.randomUUID();
definitionMap.put(name, builtSchema);
}
responseSchema = new io.swagger.v3.oas.models.media.Schema().$ref(SCHEMAS_REF_PREFIX + name);
} else {
final String ref = getDefinitionsReference(schema.getReference());
if (ref == null) {
throw new TransformerException("Invalid JSON ref");
}
responseSchema = new io.swagger.v3.oas.models.media.Schema().$ref(SCHEMAS_REF_PREFIX + ref);
}
response.content(new Content().addMediaType("application/json",
new MediaType().schema(responseSchema)));
if (operationModel instanceof Create) {
LocalizableSchema locSchema = new LocalizableSchema();
locSchema.type("string");
locSchema.description(locationDescription);
response.addHeaderObject(PARAMETER_LOCATION,
new Header().schema(locSchema));
responses.addApiResponse("201", response);
} else {
responses.addApiResponse("200", response);
}
}
if (!isEmpty(apiErrorResponses)) {
final List<ApiError> resolvedErrors = new ArrayList<>(apiErrorResponses.length);
for (final ApiError error : apiErrorResponses) {
resolvedErrors.add(resolveErrorReference(error));
}
Collections.sort(resolvedErrors, ApiError.ERROR_COMPARATOR);
final int n = resolvedErrors.size();
for (int i = 0; i < n; ++i) {
final ApiError apiError = resolvedErrors.get(i);
final int code = apiError.getCode();
final List<LocalizableString> descriptions = new ArrayList<>();
if (apiError.getDescription() != null) {
descriptions.add(apiError.getDescription());
}
for (int k = i + 1; k < n; ++k) {
final ApiError error = resolvedErrors.get(k);
if (error.getCode() == code) {
if (error.getDescription() != null) {
descriptions.add(error.getDescription());
}
++i;
}
}
final LocalizableResponse response = new LocalizableResponse();
if (descriptions.size() == 1) {
response.description(descriptions.get(0));
} else if (!descriptions.isEmpty()) {
response.description(new LocalizableString("Aggregated bullet description list") {
@Override
public String toTranslatedString(PreferredLocales locales) {
final AsciiDoc bulletedList = AsciiDoc.asciiDoc();
for (final LocalizableString listItem : descriptions) {
bulletedList.unorderedList1(listItem.toTranslatedString(locales));
}
return bulletedList.toString();
}
});
}
final JsonValue errorSchema = buildErrorSchema(apiError);
final io.swagger.v3.oas.models.media.Schema builtSchema = buildSchema(errorSchema);
final String name = addDefinitionReference(errorSchema, builtSchema);
io.swagger.v3.oas.models.media.Schema refSchema =
new io.swagger.v3.oas.models.media.Schema().$ref(SCHEMAS_REF_PREFIX + name);
response.content(new Content().addMediaType("application/json",
new MediaType().schema(refSchema)));
responses.addApiResponse(String.valueOf(code), response);
}
}
operation.setResponses(responses);
}
JsonValue buildErrorSchema(final ApiError apiError) {
String id = FRAPI_PREFIX + "models:ApiError";
JsonValue errorCauseSchema = null;
final Schema schema = apiError.getSchema();
if (schema != null && schema.getSchema().isNotNull()) {
errorCauseSchema = schema.getSchema();
id += ':' + urnSafeHash(errorCauseSchema.toString());
}
return json(object(
field(ID, id),
field(TYPE, TYPE_OBJECT),
field(REQUIRED, array("code", "message")),
field(TITLE, localizable("common.error.title")),
field(PROPERTIES, object(
field("code", object(
field(TYPE, TYPE_INTEGER),
field(DESCRIPTION, localizable("common.error.properties.code"))
)),
field("message", object(
field(TYPE, TYPE_STRING),
field(DESCRIPTION, localizable("common.error.properties.message"))
)),
field("reason", object(
field(TYPE, TYPE_STRING),
field(DESCRIPTION, localizable("common.error.properties.reason"))
)),
field("detail", object(
field(TYPE, TYPE_STRING),
field(DESCRIPTION, localizable("common.error.properties.detail"))
)),
fieldIfNotNull("cause", errorCauseSchema)
))
));
}
private ApiError resolveErrorReference(ApiError apiError) {
if (apiError.getReference() != null) {
apiError = referenceResolver.getError(apiError.getReference());
if (apiError == null) {
throw new TransformerException("Error reference not found in global error definitions");
}
}
return apiError;
}
@VisibleForTesting
Schema buildPatchRequestPayload(final PatchOperation[] patchOperations) {
final List<String> enumArray = new ArrayList<>(patchOperations.length);
for (final PatchOperation op : patchOperations) {
enumArray.add(op.name().toLowerCase(Locale.ROOT));
}
Collections.sort(enumArray);
final String operations = Joiner.on("_").join(enumArray);
final String id = FRAPI_PREFIX + "models:Patch:" + operations;
final JsonValue schemaValue = json(object(
field(ID, id),
field(TITLE, localizable("common.patch.title")),
field(TYPE, TYPE_ARRAY),
field(ITEMS, object(
field(TITLE, localizable("common.patch.items.title")),
field(TYPE, TYPE_OBJECT),
field(PROPERTIES, object(
field("operation", object(
field(TYPE, TYPE_STRING),
field(ENUM, enumArray),
field(DESCRIPTION, localizable("common.patch.items.properties.operation")),
field(REQUIRED, true))),
field("field", object(
field(DESCRIPTION, localizable("common.patch.items.properties.field")),
field(TYPE, TYPE_STRING))),
field("from", object(
field(DESCRIPTION, localizable("common.patch.items.properties.from")),
field(TYPE, TYPE_STRING))),
field("value", object(
field(DESCRIPTION, localizable("common.patch.items.properties.value")),
field(TYPE, TYPE_STRING)))
))
))
));
return Schema.schema().schema(schemaValue).build();
}
@VisibleForTesting
Info buildInfo(final LocalizableString title) {
return new LocalizableInfo()
.title(title != null ? title : new LocalizableString(apiDescription.getId()))
.description(apiDescription.getDescription())
.version(apiDescription.getVersion());
}
@VisibleForTesting
@SuppressWarnings("rawtypes")
void buildDefinitions() {
final Definitions definitions = apiDescription.getDefinitions();
if (definitions != null) {
final Set<String> definitionNames = definitions.getNames();
for (final String name : definitionNames) {
final Schema schema = definitions.get(name);
if (schema.getSchema() != null) {
definitionMap.put(name, buildSchema(schema.getSchema()));
}
}
}
if (!definitionMap.isEmpty()) {
Components components = openApi.getComponents();
if (components == null) {
components = new Components();
openApi.setComponents(components);
}
components.setSchemas(definitionMap);
}
}
/**
* Converts a JSON schema into the appropriate OpenAPI 3.0 Schema.
*
* @param schema JSON schema
* @return OpenAPI Schema
*/
@VisibleForTesting
@SuppressWarnings({ "unchecked", "rawtypes" })
io.swagger.v3.oas.models.media.Schema buildSchema(final JsonValue schema) {
final String type = getType(schema);
if (type == null) {
if (schema.isDefined("allOf")) {
return buildAllOfSchema(schema);
} else if (schema.isDefined("$ref")) {
return buildReferenceSchema(schema);
}
throw new TransformerException(unsupportedJsonSchema(schema));
}
switch (type) {
case "object":
return buildObjectSchema(schema);
case "array":
return buildArraySchema(schema);
case "any":
case "null":
return new LocalizableSchema().type(type);
case "boolean":
case "integer":
case "number":
case "string":
return buildScalarSchema(schema, type);
default:
throw new TransformerException("Unsupported JSON Schema type '" + type + "' in schema " + schema);
}
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private io.swagger.v3.oas.models.media.Schema buildAllOfSchema(final JsonValue schema) {
final List<io.swagger.v3.oas.models.media.Schema> allOf = schema.get("allOf")
.as(listOf(schemaMapper()));
if (allOf == null || allOf.isEmpty()) {
throw new TransformerException(unsupportedJsonSchema(schema));
}
final LocalizableSchema result = new LocalizableSchema();
setTitleAndDescriptionFromSchema(result, schema);
result.allOf(allOf);
return result;
}
private String unsupportedJsonSchema(final JsonValue schema) {
return "Unsupported JSON schema: expected 'type', '$ref' or non-empty 'allOf' property in: '" + schema + "'";
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private io.swagger.v3.oas.models.media.Schema buildReferenceSchema(JsonValue schema) {
final LocalizableSchema result = new LocalizableSchema();
setTitleAndDescriptionFromSchema(result, schema);
result.set$ref(schema.get("$ref").asString());
result.setProperties(buildSchemaProperties(schema));
return result;
}
@SuppressWarnings("rawtypes")
private Function<JsonValue, io.swagger.v3.oas.models.media.Schema, JsonValueException> schemaMapper() {
return new Function<JsonValue, io.swagger.v3.oas.models.media.Schema, JsonValueException>() {
@Override
public io.swagger.v3.oas.models.media.Schema apply(JsonValue value) throws JsonValueException {
return buildSchema(value);
}
};
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private io.swagger.v3.oas.models.media.Schema buildObjectSchema(final JsonValue schema) {
final LocalizableSchema result = new LocalizableSchema();
result.type("object");
result.setDiscriminator(schema.get("discriminator").asString() != null
? new io.swagger.v3.oas.models.media.Discriminator()
.propertyName(schema.get("discriminator").asString())
: null);
result.setProperties(buildSchemaProperties(schema));
final List<String> required = getArrayOfJsonString("required", schema);
if (!required.isEmpty()) {
result.setRequired(required);
}
io.swagger.v3.oas.models.media.Schema additionalProps = buildSchemaFromJson(schema.get("additionalProperties"));
if (additionalProps != null) {
result.setAdditionalProperties(additionalProps);
}
setTitleAndDescriptionFromSchema(result, schema);
return result;
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private LocalizableSchema buildScalarSchema(final JsonValue schema, final String type) {
final LocalizableSchema result = new LocalizableSchema();
result.type(type);
setTitleAndDescriptionFromSchema(result, schema);
if (schema.get("default").isNotNull()) {
result.setDefault(schema.get("default").asString());
}
final List<String> enumValues = getArrayOfJsonString("enum", schema);
if (!enumValues.isEmpty()) {
result.setEnum(enumValues);
final JsonValue options = schema.get("options");
if (options.isNotNull()) {
final List<String> enumTitles = getArrayOfJsonString("enum_titles", options);
if (!enumTitles.isEmpty()) {
result.addExtension("x-enum_titles", enumTitles);
}
}
}
if (schema.get("format").isNotNull()) {
result.setFormat(schema.get("format").asString());
if ("full-date".equals(result.getFormat()) && "string".equals(type)) {
result.setFormat("date");
}
}
return result;
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private io.swagger.v3.oas.models.media.Schema buildArraySchema(final JsonValue schema) {
final LocalizableSchema result = new LocalizableSchema();
result.type("array");
setTitleAndDescriptionFromSchema(result, schema);
result.setProperties(buildSchemaProperties(schema));
result.setItems(buildItemsSchema(schema));
return result;
}
/**
* Convert JSON schema-properties into a map of Schema objects.
*/
@VisibleForTesting
@SuppressWarnings({ "unchecked", "rawtypes" })
Map<String, io.swagger.v3.oas.models.media.Schema> buildSchemaProperties(final JsonValue schema) {
if (schema != null && schema.isNotNull()) {
final JsonValue properties = schema.get("properties");
if (properties.isNotNull()) {
final Map<String, Object> propertiesMap = properties.asMap();
final Map<String, io.swagger.v3.oas.models.media.Schema> resultMap =
new LinkedHashMap<>(propertiesMap.size() * 2);
boolean sortByPropertyOrder = false;
for (final Map.Entry<String, Object> entry : propertiesMap.entrySet()) {
final io.swagger.v3.oas.models.media.Schema propSchema;
try {
propSchema = buildSchemaFromJson(json(entry.getValue()));
} catch (RuntimeException re) {
logger.info("Json schema error: " + entry.getValue() + "\n"
+ re.getMessage(), re.fillInStackTrace());
throw re;
}
if (propSchema != null && propSchema.getExtensions() != null
&& propSchema.getExtensions().containsKey("x-propertyOrder")) {
sortByPropertyOrder = true;
}
resultMap.put(entry.getKey(), propSchema);
}
if (sortByPropertyOrder && resultMap.size() > 1) {
final List<Map.Entry<String, io.swagger.v3.oas.models.media.Schema>> entries =
new ArrayList<>(resultMap.entrySet());
Collections.sort(entries,
new Comparator<Map.Entry<String, io.swagger.v3.oas.models.media.Schema>>() {
@Override
public int compare(final Map.Entry<String, io.swagger.v3.oas.models.media.Schema> o1,
final Map.Entry<String, io.swagger.v3.oas.models.media.Schema> o2) {
final Integer v1 = o1.getValue().getExtensions() != null
? (Integer) o1.getValue().getExtensions().get("x-propertyOrder") : null;
final Integer v2 = o2.getValue().getExtensions() != null
? (Integer) o2.getValue().getExtensions().get("x-propertyOrder") : null;
if (v1 != null) {
if (v2 != null) {
return v1.compareTo(v2);
}
return -1;
}
if (v2 != null) {
return 1;
}
return 0;
}
});
final Map<String, io.swagger.v3.oas.models.media.Schema> sortedMap =
new LinkedHashMap<>(propertiesMap.size() * 2);
for (final Map.Entry<String, io.swagger.v3.oas.models.media.Schema> entry : entries) {
sortedMap.put(entry.getKey(), entry.getValue());
}
return sortedMap;
} else {
return resultMap;
}
}
}
return null;
}
/**
* Builds an OpenAPI Schema from a JSON Schema definition.
*/
@VisibleForTesting
@SuppressWarnings({ "unchecked", "rawtypes" })
io.swagger.v3.oas.models.media.Schema buildSchemaFromJson(final JsonValue schema) {
if (schema == null || schema.isNull()) {
return null;
}
final String format = schema.get("format").asString();
final String type = getType(schema);
// Handle $ref
if (schema.get("$ref").isNotNull()) {
final String ref = getDefinitionsReference(schema.get("$ref").asString());
if (ref == null) {
throw new TransformerException("Invalid JSON ref: " + schema.get("$ref").asString());
}
LocalizableSchema result = new LocalizableSchema();
result.set$ref(SCHEMAS_REF_PREFIX + ref);
setTitleAndDescriptionFromSchema(result, schema);
return result;
}
if (type == null) {
return null;
}
LocalizableSchema result = new LocalizableSchema();
switch (type) {
case "any":
case "object": {
if (hasReferenceableId(schema)) {
final io.swagger.v3.oas.models.media.Schema model = buildObjectSchema(schema);
final String name = addDefinitionReference(schema, model);
final LocalizableSchema refSchema = new LocalizableSchema();
refSchema.$ref(SCHEMAS_REF_PREFIX + name);
setTitleAndDescriptionFromSchema(refSchema, schema);
return refSchema;
} else {
result.type(type);
result.setProperties(buildSchemaProperties(schema));
final List<String> required = getArrayOfJsonString("required", schema);
if (!required.isEmpty()) {
result.setRequired(required);
}
if (schema.get("default").isNotNull()) {
result.setDefault(schema.get("default").getObject());
}
}
break;
}
case "array": {
result.type("array");
result.setItems(buildItemsSchema(schema));
if (schema.get("minItems").isNotNull()) {
result.setMinItems(schema.get("minItems").asInteger());
}
if (schema.get("maxItems").isNotNull()) {
result.setMaxItems(schema.get("maxItems").asInteger());
}
if (schema.get("uniqueItems").isNotNull()) {
result.setUniqueItems(schema.get("uniqueItems").asBoolean());
}
if (schema.get("default").isNotNull()) {
result.setDefault(schema.get("default").asList());
}
break;
}
case "boolean":
result.type("boolean");
break;
case "integer":
case "number": {
result.type(type);
if (schema.get("minimum").isNotNull()) {
result.setMinimum(BigDecimalUtil.safeValueOf(schema.get("minimum").asDouble()));
}
if (schema.get("maximum").isNotNull()) {
result.setMaximum(BigDecimalUtil.safeValueOf(schema.get("maximum").asDouble()));
}
if (schema.get("exclusiveMinimum").isNotNull()) {
result.setExclusiveMinimum(schema.get("exclusiveMinimum").asBoolean());
}
if (schema.get("exclusiveMaximum").isNotNull()) {
result.setExclusiveMaximum(schema.get("exclusiveMaximum").asBoolean());
}
break;
}
case "null":
return null;
case "string": {
result.type("string");
if (schema.get("minLength").isNotNull()) {
result.setMinLength(schema.get("minLength").asInteger());
}
if (schema.get("maxLength").isNotNull()) {
result.setMaxLength(schema.get("maxLength").asInteger());
}
if (schema.get("pattern").isNotNull()) {
result.setPattern(schema.get("pattern").asString());
}
break;
}
default:
throw new TransformerException("Unsupported JSON schema type: " + type);
}
if (!isEmpty(format)) {
result.setFormat(format);
if ("full-date".equals(format) && "string".equals(type)) {
result.setFormat("date");
}
}
if (!"object".equals(type) && !"array".equals(type) && schema.get("default").isNotNull()) {
result.setDefault(schema.get("default").getObject().toString());
}
setTitleAndDescriptionFromSchema(result, schema);
final String readPolicy = schema.get("readPolicy").asString();
if (!isEmpty(readPolicy)) {
result.addExtension("x-readPolicy", readPolicy);
}
if (schema.get("returnOnDemand").isNotNull()) {
result.addExtension("x-returnOnDemand", schema.get("returnOnDemand").asBoolean());
}
final Boolean readOnly = schema.get("readOnly").asBoolean();
if (TRUE.equals(readOnly)) {
result.setReadOnly(TRUE);
} else {
final String writePolicy = schema.get("writePolicy").asString();
if (!isEmpty(writePolicy)) {
result.addExtension("x-writePolicy", writePolicy);
if (schema.get("errorOnWritePolicyFailure").isNotNull()) {
result.addExtension("x-errorOnWritePolicyFailure",
schema.get("errorOnWritePolicyFailure").asBoolean());
}
}
}
final Integer propertyOrder = schema.get("propertyOrder").asInteger();
if (propertyOrder != null) {
result.addExtension("x-propertyOrder", propertyOrder);
}
// Enum values
final List<String> enumValues = getArrayOfJsonString("enum", schema);
if (!enumValues.isEmpty()) {
result.setEnum(enumValues);
final JsonValue options = schema.get("options");
if (options.isNotNull()) {
final List<String> enumTitles = getArrayOfJsonString("enum_titles", options);
if (!enumTitles.isEmpty()) {
result.addExtension("x-enum_titles", enumTitles);
}
}
}
return result;
}
@SuppressWarnings("rawtypes")
private io.swagger.v3.oas.models.media.Schema buildItemsSchema(final JsonValue schema) {
if (!schema.isDefined("items")) {
final LocalizableSchema result = new LocalizableSchema();
result.type("any");
return result;
}
final JsonValue items = schema.get("items");
if (items.isNull()) {
throw new TransformerException("JSON-array 'items' field cannot be null: " + schema);
}
return buildSchemaFromJson(items);
}
private List<String> getArrayOfJsonString(final String field, final JsonValue schema) {
final JsonValue value = schema.get(field);
if (value.isNotNull() && value.isCollection()) {
return value.asList(String.class);
}
return Collections.emptyList();
}
private String getType(final JsonValue schema) {
final JsonValue value = schema.get("type");
if (value.isList()) {
final List<String> list = value.asList(String.class);
list.remove("null");
if (list.size() == 1) {
return list.get(0);
}
logger.trace("Simplifying array of types {} to 'any' type", value);
return "any";
}
return value.asString();
}
private boolean hasReferenceableId(final JsonValue schema) {
return isReferenceableId(schema.get("id").asString());
}
private boolean isReferenceableId(final String id) {
return id != null && (id.startsWith(URN_JSONSCHEMA_PREFIX) || id.startsWith(FRAPI_PREFIX));
}
@VisibleForTesting
@SuppressWarnings("rawtypes")
String addDefinitionReference(final JsonValue schema, final io.swagger.v3.oas.models.media.Schema model) {
if (hasReferenceableId(schema)) {
final String id = schema.get("id").asString();
final io.swagger.v3.oas.models.media.Schema existingModel = definitionMap.put(id, model);
if (existingModel != null && !existingModel.equals(model)) {
logger.info("Replacing schema definition with id: " + id);
}
return id;
}
return null;
}
@VisibleForTesting
String getDefinitionsReference(final Reference reference) {
if (reference != null) {
return getDefinitionsReference(reference.getValue());
}
return null;
}
@VisibleForTesting
String getDefinitionsReference(final String reference) {
if (!isEmpty(reference)) {
if (isReferenceableId(reference)) {
return reference;
}
// Support both old-style #/definitions/ and new-style #/components/schemas/ references
final String oldPrefix = "#/definitions/";
int start = reference.indexOf(oldPrefix);
if (start != -1) {
final String s = reference.substring(start + oldPrefix.length());
if (!s.isEmpty()) {
return s;
}
}
start = reference.indexOf(SCHEMAS_REF_PREFIX);
if (start != -1) {
final String s = reference.substring(start + SCHEMAS_REF_PREFIX.length());
if (!s.isEmpty()) {
return s;
}
}
}
return null;
}
private void setTitleAndDescriptionFromSchema(LocalizableTitleAndDescription<?> model, JsonValue schema) {
setTitleFromJsonValue(model, schema.get("title"));
setDescriptionFromJsonValue(model, schema.get("description"));
}
static void setTitleFromJsonValue(LocalizableTitleAndDescription<?> model, JsonValue source) {
if (source.isString()) {
model.title(source.asString());
} else {
model.title((LocalizableString) source.getObject());
}
}
static void setDescriptionFromJsonValue(LocalizableTitleAndDescription<?> model, JsonValue source) {
if (source.isString()) {
model.description(source.asString());
} else {
model.description((LocalizableString) source.getObject());
}
}
private static String urnSafeHash(final String s) {
return BaseEncoding.base64Url().encode(Hashing.sha1().hashString(s, UTF_8).asBytes());
}
}