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 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 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.wrensecurity.guava.common.base.Joiner;
import org.wrensecurity.guava.common.hash.Hashing;
import org.wrensecurity.guava.common.io.BaseEncoding;
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 io.swagger.models.Info;
import io.swagger.models.Model;
import io.swagger.models.ModelImpl;
import io.swagger.models.Operation;
import io.swagger.models.Path;
import io.swagger.models.RefModel;
import io.swagger.models.Response;
import io.swagger.models.Scheme;
import io.swagger.models.Swagger;
import io.swagger.models.parameters.HeaderParameter;
import io.swagger.models.parameters.RefParameter;
import io.swagger.models.properties.AbstractNumericProperty;
import io.swagger.models.properties.Property;
import io.swagger.models.properties.RefProperty;
import io.swagger.models.refs.RefType;
/**
* Transforms an {@link ApiDescription} into an OpenAPI/Swagger model.
*
* @see <a href="https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md">OpenAPI 2.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";
/**
* 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 Swagger swagger;
private final ReferenceResolver referenceResolver;
private final ApiDescription apiDescription;
private final Map<String, Model> definitionMap = new HashMap<>();
/** {@code Location}-header property. */
private final LocalizableStringProperty locationProperty = new LocalizableStringProperty()
.description(new LocalizableString(LOCATION_PARAMETER_DESCRIPTION, getClass().getClassLoader()));
/** Default constructor that is only used by unit tests. */
@VisibleForTesting
OpenApiTransformer() {
swagger = 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");
swagger = new SwaggerExtended()
.scheme(secure ? Scheme.HTTPS : Scheme.HTTP)
.host(host)
.consumes("application/json")
.consumes("text/plain")
.consumes("multipart/form-data")
.produces("application/json")
.info(buildInfo(title));
if (!isEmpty(basePath)) {
// make sure path starts with forward-slash (OpenAPI 2.0 spec), and does not end with one
swagger.basePath(PathUtil.buildPath(basePath));
}
referenceResolver = new ReferenceResolver(apiDescription);
if (externalApiDescriptions != null) {
referenceResolver.registerAll(externalApiDescriptions);
}
}
/**
* Transforms an {@link ApiDescription} into a {@code Swagger} 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 Swagger} model
*/
public static Swagger 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 a {@code Swagger} model.
* <p>
* Note: The returned descriptor does not contain an {@code Info} object, a base path, a host or a scheme, 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 Swagger} model
*/
public static Swagger 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 a {@code Swagger} model.
*
* @return {@code Swagger} model
*/
private Swagger doExecute() {
buildParameters();
buildPaths();
buildDefinitions();
return swagger;
}
/** 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));
swagger.addParameter(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));
swagger.addParameter(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));
swagger.addParameter(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.required(true);
putIfNoneMatchParameter.setEnum(asList("*"));
swagger.addParameter(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");
swagger.addParameter(PARAMETER_IF_NONE_MATCH_REV_ONLY, readIfNoneMatchParameter);
// IF-MATCH
final LocalizableHeaderParameter ifMatchParameter = new LocalizableHeaderParameter();
ifMatchParameter.setName(PARAMETER_IF_MATCH);
ifMatchParameter.setType("string");
ifMatchParameter.setDefault("*");
swagger.addParameter(ifMatchParameter.getName(), ifMatchParameter);
}
/** Traverse CREST API Descriptor paths, to build the Swagger model. */
private void buildPaths() {
final Paths paths = apiDescription.getPaths();
if (paths != null) {
final Map<String, Path> 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 is start of URL-fragment for path (e.g., /myPath#1.0)
versionName = version.toString();
}
final Resource resource = resolveResourceReference(versionedPath.get(version));
// make sure path starts with forward-slash (OpenAPI 2.0 spec), and does not end with one
final String normalizedPathName = pathName.isEmpty() ? "/" : PathUtil.buildPath(pathName);
buildResourcePaths(resource, normalizedPathName, null, versionName,
Collections.<Parameter>emptyList(), pathMap);
}
}
swagger.setPaths(pathMap);
}
}
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, Path> pathMap) {
// always show version at end of paths, inside the URL fragment
final boolean hasResourceVersion = !isEmpty(resourceVersion);
final String pathNamespace = hasResourceVersion
? normalizeName(pathName, resourceVersion) : normalizeName(pathName);
// group resource endpoints by tag
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;
}
};
swagger.addTag(new LocalizableTag().name(tag));
}
Schema resourceSchema = null;
if (resource.getResourceSchema() != null) {
resourceSchema = resource.getResourceSchema();
}
// resource-parameters are inherited by operations, items, and subresources
final List<Parameter> operationParameters = unmodifiableList(
mergeParameters(new ArrayList<>(parameters), resource.getParameters()));
// create Swagger operations from CREST operations
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);
// create collection-items and sub-resources
buildItems(resource, pathName, tag, resourceVersion, parameters, pathMap);
buildSubResources(resource.getSubresources(), pathName, resourceVersion, parameters, pathMap);
}
/**
* Builds {@link Resource} collection-items.
*
* @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 buildItems(final Resource resource, final String pathName, final LocalizableString parentTag,
final String resourceVersion, final List<Parameter> parameters, final Map<String, Path> pathMap) {
if (resource.getItems() != null) {
final Items items = resource.getItems();
// an items-resource inherits some fields from its parent, so build combined resource
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);
}
}
/**
* Builds {@link Resource} sub-resources.
*
* @param subResources CREST sub-resources
* @param pathName Resource path-name
* @param resourceVersion Resource version-name or empty-string
* @param parameters CREST operation parameters
* @param pathMap Output for OpenAPI paths that are constructed
*/
private void buildSubResources(final SubResources subResources, final String pathName,
final String resourceVersion, final List<Parameter> parameters, final Map<String, Path> pathMap) {
if (subResources != null) {
// recursively build sub-resources
final List<String> subPathNames = new ArrayList<>(subResources.getNames());
Collections.sort(subPathNames);
for (final String name : subPathNames) {
// create path-parameters, for any path-variables found in subPathName
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);
}
}
}
/**
* Build create-operation.
*
* @param resource CREST resource
* @param pathName Path-name, which is the actual HTTP path
* @param pathNamespace Unique path-namespace
* @param tag Tag for grouping operations together by resource/version
* @param resourceVersion Resource version-name or empty-string
* @param resourceSchema Resource schema or {@code null}
* @param parameters CREST operation parameters
* @param pathMap Output for OpenAPI paths that are constructed
*/
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, Path> 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.addParameter(new RefParameter(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());
}
}
}
/**
* Build read-operation.
*
* @param resource CREST resource
* @param pathName Path-name, which is the actual HTTP path
* @param pathNamespace Unique path-namespace
* @param tag Tag for grouping operations together by resource/version
* @param resourceVersion Resource version-name or empty-string
* @param resourceSchema Resource schema or {@code null}
* @param parameters CREST operation parameters
* @param pathMap Output for OpenAPI paths that are constructed
*/
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, Path> 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.addParameter(new RefParameter(PARAMETER_MIME_TYPE));
if (resource.isMvccSupported()) {
operation.addParameter(new RefParameter(PARAMETER_IF_NONE_MATCH_REV_ONLY));
}
addOperation(operation, "get", pathName, operationPathFragment, resourceVersion, tag, pathMap);
}
}
/**
* Build update-operation.
*
* @param resource CREST resource
* @param pathName Path-name, which is the actual HTTP path
* @param pathNamespace Unique path-namespace
* @param tag Tag for grouping operations together by resource/version
* @param resourceVersion Resource version-name or empty-string
* @param resourceSchema Resource schema or {@code null}
* @param parameters CREST operation parameters
* @param pathMap Output for OpenAPI paths that are constructed
*/
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, Path> 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.addParameter(new RefParameter(PARAMETER_IF_MATCH));
}
addOperation(operation, "put", pathName, operationPathFragment, resourceVersion, tag, pathMap);
}
}
/**
* Build delete-operation.
*
* @param resource CREST resource
* @param pathName Path-name, which is the actual HTTP path
* @param pathNamespace Unique path-namespace
* @param tag Tag for grouping operations together by resource/version
* @param resourceVersion Resource version-name or empty-string
* @param resourceSchema Resource schema or {@code null}
* @param parameters CREST operation parameters
* @param pathMap Output for OpenAPI paths that are constructed
*/
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, Path> 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.addParameter(new RefParameter(PARAMETER_IF_MATCH));
}
addOperation(operation, "delete", pathName, operationPathFragment, resourceVersion, tag, pathMap);
}
}
/**
* Build patch-operation.
*
* @param resource CREST resource
* @param pathName Path-name, which is the actual HTTP path
* @param pathNamespace Unique path-namespace
* @param tag Tag for grouping operations together by resource/version
* @param resourceVersion Resource version-name or empty-string
* @param resourceSchema Resource schema or {@code null}
* @param parameters CREST operation parameters
* @param pathMap Output for OpenAPI paths that are constructed
*/
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, Path> 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.addParameter(new RefParameter(PARAMETER_IF_MATCH));
}
addOperation(operation, "patch", pathName, operationPathFragment, resourceVersion, tag, pathMap);
}
}
/**
* Build action-operations.
*
* @param resource CREST resource
* @param pathName Path-name, which is the actual HTTP path
* @param pathNamespace Unique path-namespace
* @param tag Tag for grouping operations together by resource/version
* @param resourceVersion Resource version-name or empty-string
* @param parameters CREST operation parameters
* @param pathMap Output for OpenAPI paths that are constructed
*/
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, Path> 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.addParameter(actionParameter);
addOperation(operation, "post", pathName, actionPathFragment, resourceVersion, tag, pathMap);
}
}
}
/**
* Build query-operations.
*
* @param resource CREST resource
* @param pathName Path-name, which is the actual HTTP path
* @param pathNamespace Unique path-namespace
* @param tag Tag for grouping operations together by resource/version
* @param resourceVersion Resource version-name or empty-string
* @param resourceSchema Resource schema or {@code null}
* @param parameters CREST operation parameters
* @param pathMap Output for OpenAPI paths that are constructed
*/
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, Path> 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()))) {
// make query-response schema an array of values
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 {
// already an array or a reference (TODO might not be an array)
responsePayload = resourceSchema;
}
final LocalizableOperation operation = buildOperation(query, queryNamespace, null, responsePayload,
parameters);
operation.setSummary(summary);
operation.addParameter(queryParameter);
final LocalizableQueryParameter pageSizeParamter = new LocalizableQueryParameter();
pageSizeParamter.setName("_pageSize");
pageSizeParamter.setType("integer");
operation.addParameter(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.addParameter(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._enum(totalPagedResultsPolicyValues);
operation.addParameter(totalPagedResultsPolicyParameter);
if (query.getType() != QueryType.ID) {
// _sortKeys parameter is not supported for ID queries
final LocalizableQueryParameter sortKeysParameter = new LocalizableQueryParameter();
sortKeysParameter.setName("_sortKeys");
sortKeysParameter.setType("string");
if (!isEmpty(query.getSupportedSortKeys())) {
sortKeysParameter.setEnum(asList(query.getSupportedSortKeys()));
}
operation.addParameter(sortKeysParameter);
}
addOperation(operation, "get", pathName, queryPathFragment, resourceVersion, tag, pathMap);
}
}
}
/**
* Constructs a LocalizableString where the value should be the key in the 'ApiDescription' bundle.
*
* @param value resource bundle key to the 'ApiDescription' bundle.
* @return LocalizableString where the value is prefixed with the I18N_PREFIX constant and uses the local
* classloader of this class.
*/
private static LocalizableString localizable(String value) {
return new LocalizableString(I18N_PREFIX + value, OpenApiTransformer.class.getClassLoader());
}
/**
* Builds a Swagger operation.
*
* @param operationModel CREST operation
* @param operationNamespace Unique operation-namespace
* @param requestPayload Request payload or {@code null}
* @param responsePayload Response payload
* @param parameters CREST operation parameters
* @return Swagger operation
*/
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;
}
/**
* Adds an OpenAPI {@code Operation} to a given path, and handles OpenAPI's inability to overload paths/operations
* by adding a URL-fragment to the path when necessary.
*
* @param operation OpenAPI operation
* @param method HTTP method (e.g., get, post, etc.)
* @param pathName Path name
* @param pathFragment Unique path-fragment, for overloading paths
* @param resourceVersion Resource version-name or empty-string
* @param tag Tag used to group OpenAPI operations or {@code null}
* @param pathMap Path map
*/
private void addOperation(final LocalizableOperation operation, final String method, final String pathName,
final String pathFragment, final String resourceVersion, final LocalizableString tag,
final Map<String, Path> pathMap) {
boolean showPathFragment = false;
if (!isEmpty(resourceVersion)) {
showPathFragment = true;
operation.setVendorExtension("x-resourceVersion", resourceVersion);
operation.addParameter(new HeaderParameter()
.name(AcceptApiVersionHeader.NAME)
.type("string")
.required(true)
._enum(singletonList(AcceptApiVersionHeader.RESOURCE + "=" + resourceVersion)));
}
if (!isEmpty(tag.toString())) {
operation.addTag(tag);
}
Path operationPath = pathMap.get(pathName);
if (operationPath == null) {
operationPath = new Path();
} else if (!showPathFragment) {
// path already exists, so make sure it is unique
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) {
// create a unique path by adding a URL-fragment at end
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 Path();
pathMap.put(uniquePath, operationPath);
} else {
pathMap.put(pathName, operationPath);
}
if (operationPath.set(method, operation) == null) {
throw new TransformerException("Unsupported method: " + method);
}
}
/**
* Marks a Swagger operation as <em>deprecated</em> when CREST operation is deprecated or removed.
*
* @param stability CREST operation stability or {@code null}
* @param operation Swagger operation
*/
private void applyOperationStability(final Stability stability, final Operation operation) {
if (stability == Stability.DEPRECATED || stability == Stability.REMOVED) {
operation.setDeprecated(TRUE);
}
}
/**
* Converts CREST operation parameters (e.g., path variables, query fields) into Swagger operation parameters.
* <p>
* This method assumes at {@link org.forgerock.api.enums.ParameterSource#ADDITIONAL} parameters are
* query-parameters, which would need to be changed with post-processing if, for example, they should be HTTP
* headers.
* </p>
*
* @param parameters CREST operation parameters
* @param operation Swagger operation
*/
private void applyOperationParameters(final List<Parameter> parameters, final Operation operation) {
if (!parameters.isEmpty()) {
for (final Parameter parameter : parameters) {
final LocalizableSerializableParameter operationParameter;
// NOTE: request-payload BodyParameter is applied elsewhere
switch (parameter.getSource()) {
case PATH:
operationParameter = new LocalizablePathParameter();
break;
case ADDITIONAL:
// we assume that additional parameters are query-parameters, which would need to be changed
// with post-processing if, for example, they should be HTTP headers
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())) {
// enum_titles only provided with enum values
operationParameter.getVendorExtensions().put("x-enum_titles",
asList(parameter.getEnumTitles()));
}
}
operation.addParameter(operationParameter);
}
}
// apply common parameters
operation.addParameter(new RefParameter(PARAMETER_FIELDS));
operation.addParameter(new RefParameter(PARAMETER_PRETTY_PRINT));
}
/**
* Defines a request-payload for a Swagger operation.
*
* @param schema JSON Schema or {@code null}
* @param operation Swagger operation
*/
private void applyOperationRequestPayload(final Schema schema, final Operation operation) {
if (schema != null) {
final Model model;
if (schema.getSchema() != null) {
if (hasReferenceableId(schema.getSchema())) {
final String name = addDefinitionReference(schema.getSchema(), buildModel(schema.getSchema()));
model = new RefModel(name);
} else {
model = buildModel(schema.getSchema());
}
} else {
final String ref = getDefinitionsReference(schema.getReference());
if (ref == null) {
throw new TransformerException("Invalid JSON ref");
}
model = new RefModel(ref);
}
final LocalizableBodyParameter parameter = new LocalizableBodyParameter();
parameter.setName("requestPayload");
parameter.setSchema(model);
parameter.setRequired(true);
operation.addParameter(parameter);
}
}
/**
* Defines response-payloads, which may be a combination of success and error responses, for a Swagger operation.
*
* @param schema Success-response JSON schema
* @param apiErrorResponses ApiError responses
* @param operationModel CREST operation
* @param operation Swagger operation
*/
private void applyOperationResponsePayloads(final Schema schema, final ApiError[] apiErrorResponses,
final org.forgerock.api.models.Operation operationModel, final Operation operation) {
final Map<String, Response> responses = new HashMap<>();
if (schema != null) {
final Response response = new Response();
response.description("Success");
if (schema.getSchema() != null) {
final Model model = buildModel(schema.getSchema());
String name = addDefinitionReference(schema.getSchema(), model);
if (name == null) {
// no suitable JSON Ref ID exists, so create a temporary one
name = "urn:uuid:" + UUID.randomUUID();
definitionMap.put(name, model);
}
response.schema(new RefProperty(name));
} else {
final String ref = getDefinitionsReference(schema.getReference());
if (ref == null) {
throw new TransformerException("Invalid JSON ref");
}
response.schema(new RefProperty(ref));
}
if (operationModel instanceof Create) {
response.addHeader(PARAMETER_LOCATION, locationProperty);
responses.put("201", response);
} else {
responses.put("200", response);
}
}
if (!isEmpty(apiErrorResponses)) {
// sort by apiError codes, so that same-codes can be merged together, because Swagger cannot overload codes
// resolve error references before sorting
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);
// for a given apiError-code, create a bulleted-list of descriptions, if there is more than one to merge
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) {
// TODO build composite schema with detailsSchema??? error.getSchema();
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) {
// Create a bulleted-list using single-asterisk, as supported by GitHub Flavored Markdown
final AsciiDoc bulletedList = AsciiDoc.asciiDoc();
for (final LocalizableString listItem : descriptions) {
bulletedList.unorderedList1(listItem.toTranslatedString(locales));
}
return bulletedList.toString();
}
});
}
final JsonValue errorSchema = buildErrorSchema(apiError);
final Model model = buildModel(errorSchema);
final String name = addDefinitionReference(errorSchema, model);
response.schema(new RefProperty(name));
responses.put(String.valueOf(code), response);
}
}
operation.setResponses(responses);
}
/**
* Build JSON Schema for a given API error.
*
* @param apiError API error
* @return JSON Schema
*/
JsonValue buildErrorSchema(final ApiError apiError) {
// generate unique JSON Schema ID for the error definition
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;
}
/**
* Builds a request-payload for a patch-operation.
*
* @param patchOperations Supported CREST path-operations
* @return JSON schema for request-payload
*/
@VisibleForTesting
Schema buildPatchRequestPayload(final PatchOperation[] patchOperations) {
// see org.forgerock.json.resource.PatchOperation#PatchOperation
final List<String> enumArray = new ArrayList<>(patchOperations.length);
for (final PatchOperation op : patchOperations) {
enumArray.add(op.name().toLowerCase(Locale.ROOT));
}
// sort patch-operations, so that we can generate a stable/unique value, to use as part of schema ID
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();
}
/**
* Builds Swagger info-model, which describes the API (e.g., title, version, description).
*
* @param title API title
* @return Info model
*/
@VisibleForTesting
Info buildInfo(final LocalizableString title) {
return new LocalizableInfo()
.title(title != null ? title : new LocalizableString(apiDescription.getId()))
.description(apiDescription.getDescription())
.version(apiDescription.getVersion());
}
/** Converts global CREST schema definitions into glabal Swagger schema definitions. */
@VisibleForTesting
void buildDefinitions() {
final Definitions definitions = apiDescription.getDefinitions();
if (definitions != null) {
// named schema definitions
final Set<String> definitionNames = definitions.getNames();
for (final String name : definitionNames) {
final Schema schema = definitions.get(name);
if (schema.getSchema() != null) {
definitionMap.put(name, buildModel(schema.getSchema()));
}
}
}
if (!definitionMap.isEmpty()) {
swagger.setDefinitions(definitionMap);
}
}
/**
* Converts a JSON schema into the appropriate Swagger model (e.g., object, array, string, integer, etc.).
*
* @param schema JSON schema
* @return Swagger schema model
*/
@VisibleForTesting
Model buildModel(final JsonValue schema) {
final String type = getType(schema);
if (type == null) {
if (schema.isDefined("allOf")) {
return buildAllOfModel(schema);
} else if (schema.isDefined("$ref")) {
return buildReferenceModel(schema);
}
throw new TransformerException(unsupportedJsonSchema(schema));
}
switch (type) {
case "object":
return buildObjectModel(schema);
case "array":
return buildArrayModel(schema);
case "any":
case "null":
return new ModelImpl().type(type);
case "boolean":
case "integer":
case "number":
case "string":
return buildScalarModel(schema, type);
default:
throw new TransformerException("Unsupported JSON Schema type '" + type + "' in schema " + schema);
}
}
private Model buildAllOfModel(final JsonValue schema) {
final List<Model> allOf = schema.get("allOf").as(listOf(model()));
if (allOf == null || allOf.isEmpty()) {
throw new TransformerException(unsupportedJsonSchema(schema));
}
final LocalizableComposedModel model = new LocalizableComposedModel();
setTitleAndDescriptionFromSchema(model, schema);
model.setAllOf(allOf);
// TODO external-docs URLs
return model;
}
private String unsupportedJsonSchema(final JsonValue schema) {
return "Unsupported JSON schema: expected 'type', '$ref' or non-empty 'allOf' property in: '" + schema + "'";
}
private Model buildReferenceModel(JsonValue schema) {
final LocalizableRefModel model = new LocalizableRefModel();
setTitleAndDescriptionFromSchema(model, schema);
model.setReference(schema.get("$ref").asString());
model.setProperties(buildProperties(schema));
// TODO external-docs URLs
return model;
}
private Function<JsonValue, Model, JsonValueException> model() {
return new Function<JsonValue, Model, JsonValueException>() {
@Override
public Model apply(JsonValue value) throws JsonValueException {
return buildModel(value);
}
};
}
private Model buildObjectModel(final JsonValue schema) {
final LocalizableModelImpl model = new LocalizableModelImpl();
model.type("object");
model.setDiscriminator(schema.get("discriminator").asString());
model.setProperties(buildProperties(schema));
final List<String> required = getArrayOfJsonString("required", schema);
if (!required.isEmpty()) {
model.setRequired(required);
}
model.setAdditionalProperties(buildProperty(schema.get("additionalProperties")));
setTitleAndDescriptionFromSchema(model, schema);
// TODO external-docs URLs
return model;
}
private LocalizableModelImpl buildScalarModel(final JsonValue schema, final String type) {
final LocalizableModelImpl model = new LocalizableModelImpl();
model.type(type);
setTitleAndDescriptionFromSchema(model, schema);
if (schema.get("default").isNotNull()) {
model.setDefaultValue(schema.get("default").asString());
}
final List<String> enumValues = getArrayOfJsonString("enum", schema);
if (!enumValues.isEmpty()) {
model.setEnum(enumValues);
// enum_titles only provided with enum values
final JsonValue options = schema.get("options");
if (options.isNotNull()) {
final List<String> enumTitles = getArrayOfJsonString("enum_titles", options);
if (!enumTitles.isEmpty()) {
model.setVendorExtension("x-enum_titles", enumTitles);
}
}
}
if (schema.get("format").isNotNull()) {
// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#dataTypeFormat
model.setFormat(schema.get("format").asString());
if ("full-date".equals(model.getFormat()) && "string".equals(type)) {
// Swagger normalizes full-date to date format
model.setFormat("date");
}
}
// TODO external-docs URLs
return model;
}
/**
* Converts a JSON schema, representing an array-type, into a Swagger array-model.
*
* @param schema JSON schema
* @return Swagger array-schema model
*/
private Model buildArrayModel(final JsonValue schema) {
final LocalizableArrayModel model = new LocalizableArrayModel();
setTitleAndDescriptionFromSchema(model, schema);
model.setProperties(buildProperties(schema));
model.setItems(buildItemsProperty(schema));
// TODO external-docs URLs
return model;
}
/**
* Convert JSON schema-properties into a Swagger named-properties map, where the key is the JSON field and
* the value is the JSON schema for that field.
*
* @param schema JSON schema containing a <em>properties</em> field
* @return Swagger named-properties map
*/
@VisibleForTesting
Map<String, Property> buildProperties(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, Property> resultMap = new LinkedHashMap<>(propertiesMap.size() * 2);
boolean sortByPropertyOrder = false;
for (final Map.Entry<String, Object> entry : propertiesMap.entrySet()) {
final Property property;
try {
property = buildProperty(json(entry.getValue()));
} catch (RuntimeException re) {
//json schema can be valid but fail on building the properties
logger.info("Json schema error: " + entry.getValue() + "\n"
+ re.getMessage(), re.fillInStackTrace());
throw re;
}
if (!sortByPropertyOrder && property.getVendorExtensions().containsKey("x-propertyOrder")) {
sortByPropertyOrder = true;
}
resultMap.put(entry.getKey(), property);
}
if (sortByPropertyOrder && resultMap.size() > 1) {
// sort by x-propertyOrder vendor extension
final List<Map.Entry<String, Property>> entries = new ArrayList<>(resultMap.entrySet());
Collections.sort(entries, new Comparator<Map.Entry<String, Property>>() {
@Override
public int compare(final Map.Entry<String, Property> o1, final Map.Entry<String, Property> o2) {
// null values appear at end after sorting
final Integer v1 = (Integer) o1.getValue().getVendorExtensions().get("x-propertyOrder");
final Integer v2 = (Integer) o2.getValue().getVendorExtensions().get("x-propertyOrder");
if (v1 != null) {
if (v2 != null) {
return v1.compareTo(v2);
}
return -1;
}
if (v2 != null) {
return 1;
}
return 0;
}
});
final Map<String, Property> sortedMap = new LinkedHashMap<>(propertiesMap.size() * 2);
for (final Map.Entry<String, Property> entry : entries) {
sortedMap.put(entry.getKey(), entry.getValue());
}
return sortedMap;
} else {
return resultMap;
}
}
}
return null;
}
/**
* Builds a Swagger property representing a JSON Schema definition, where custom JSON Schema extensions are
* added as Swagger vendor-extensions.
*
* @param schema JSON Schema
* @return Swagger property representing the JSON Schema
*/
@VisibleForTesting
Property buildProperty(final JsonValue schema) {
if (schema == null || schema.isNull()) {
return null;
}
// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#dataTypeFormat
final String format = schema.get("format").asString();
final LocalizableProperty abstractProperty = toLocalizableProperty(schema, format);
if (abstractProperty == null) {
return null;
}
if (!isEmpty(format)) {
abstractProperty.setFormat(format);
}
if (!(abstractProperty instanceof LocalizableObjectProperty
|| abstractProperty instanceof LocalizableArrayProperty)
&& schema.get("default").isNotNull()) {
// object and array are handled in toLocalizableProperty
abstractProperty.setDefault(schema.get("default").getObject().toString());
}
setTitleAndDescriptionFromSchema(abstractProperty, schema);
final String readPolicy = schema.get("readPolicy").asString();
if (!isEmpty(readPolicy)) {
abstractProperty.setVendorExtension("x-readPolicy", readPolicy);
}
if (schema.get("returnOnDemand").isNotNull()) {
abstractProperty.setVendorExtension("x-returnOnDemand", schema.get("returnOnDemand").asBoolean());
}
final Boolean readOnly = schema.get("readOnly").asBoolean();
if (TRUE.equals(readOnly)) {
abstractProperty.setReadOnly(TRUE);
} else {
// write-policy only relevant when NOT read-only
final String writePolicy = schema.get("writePolicy").asString();
if (!isEmpty(writePolicy)) {
abstractProperty.setVendorExtension("x-writePolicy", writePolicy);
if (schema.get("errorOnWritePolicyFailure").isNotNull()) {
abstractProperty.setVendorExtension("x-errorOnWritePolicyFailure",
schema.get("errorOnWritePolicyFailure").asBoolean());
}
}
}
// https://github.com/jdorn/json-editor#property-ordering
final Integer propertyOrder = schema.get("propertyOrder").asInteger();
if (propertyOrder != null) {
abstractProperty.setVendorExtension("x-propertyOrder", propertyOrder);
}
return abstractProperty;
}
private LocalizableProperty toLocalizableProperty(final JsonValue schema, final String format) {
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());
}
return new LocalizableRefProperty(ref);
}
final String type = getType(schema);
switch (type) {
case "any":
case "object": {
if (hasReferenceableId(schema)) {
// this object has a unique ID, so register it in the definitions, so that JSON References can be used
final Model model = buildObjectModel(schema);
final String name = addDefinitionReference(schema, model);
return new LocalizableRefProperty(RefType.DEFINITION.getInternalPrefix() + name);
} else {
final LocalizableObjectProperty property = new LocalizableObjectProperty();
property.setProperties(buildProperties(schema));
property.setRequiredProperties(getArrayOfJsonString("required", schema));
if (schema.get("default").isNotNull()) {
property.setDefault(schema.get("default").getObject());
}
property.setType(type);
return property;
}
}
case "array": {
final LocalizableArrayProperty property = new LocalizableArrayProperty();
property.setItems(buildItemsProperty(schema));
property.setMinItems(schema.get("minItems").asInteger());
property.setMaxItems(schema.get("maxItems").asInteger());
property.setUniqueItems(schema.get("uniqueItems").asBoolean());
if (schema.get("default").isNotNull()) {
property.setDefault(schema.get("default").asList());
}
return property;
}
case "boolean":
return new LocalizableBooleanProperty();
case "integer": {
final AbstractNumericProperty property;
if ("int64".equals(format)) {
property = new LocalizableLongProperty();
} else {
property = new LocalizableIntegerProperty();
}
property.setMinimum(BigDecimalUtil.safeValueOf(schema.get("minimum").asDouble()));
property.setMaximum(BigDecimalUtil.safeValueOf(schema.get("maximum").asDouble()));
property.setExclusiveMinimum(schema.get("exclusiveMinimum").asBoolean());
property.setExclusiveMaximum(schema.get("exclusiveMaximum").asBoolean());
return (LocalizableProperty) property;
}
case "number": {
final AbstractNumericProperty property;
if (isEmpty(format)) {
// ambiguous
property = new LocalizableDoubleProperty();
} else {
switch (format) {
case "int32":
property = new LocalizableIntegerProperty();
break;
case "int64":
property = new LocalizableLongProperty();
break;
case "float":
property = new LocalizableFloatProperty();
break;
case "double":
default:
property = new LocalizableDoubleProperty();
break;
}
}
property.setMinimum(BigDecimalUtil.safeValueOf(schema.get("minimum").asDouble()));
property.setMaximum(BigDecimalUtil.safeValueOf(schema.get("maximum").asDouble()));
property.setExclusiveMinimum(schema.get("exclusiveMinimum").asBoolean());
property.setExclusiveMaximum(schema.get("exclusiveMaximum").asBoolean());
return (LocalizableProperty) property;
}
case "null":
return null;
case "string": {
if (isEmpty(format)) {
final LocalizableStringProperty property = new LocalizableStringProperty();
property.setMinLength(schema.get("minLength").asInteger());
property.setMaxLength(schema.get("maxLength").asInteger());
property.setPattern(schema.get("pattern").asString());
return property;
}
switch (format) {
case "byte":
return new LocalizableByteArrayProperty();
case "binary": {
final LocalizableBinaryProperty property = new LocalizableBinaryProperty();
property.setMinLength(schema.get("minLength").asInteger());
property.setMaxLength(schema.get("maxLength").asInteger());
property.setPattern(schema.get("pattern").asString());
return property;
}
case "date":
case "full-date":
return new LocalizableDateProperty();
case "date-time":
return new LocalizableDateTimeProperty();
case "password": {
final LocalizablePasswordProperty property = new LocalizablePasswordProperty();
property.setMinLength(schema.get("minLength").asInteger());
property.setMaxLength(schema.get("maxLength").asInteger());
property.setPattern(schema.get("pattern").asString());
return property;
}
case "uuid": {
final LocalizableUUIDProperty property = new LocalizableUUIDProperty();
property.setMinLength(schema.get("minLength").asInteger());
property.setMaxLength(schema.get("maxLength").asInteger());
property.setPattern(schema.get("pattern").asString());
return property;
}
default: {
final LocalizableStringProperty property = new LocalizableStringProperty();
property.setMinLength(schema.get("minLength").asInteger());
property.setMaxLength(schema.get("maxLength").asInteger());
property.setPattern(schema.get("pattern").asString());
return property;
}
}
}
default:
throw new TransformerException("Unsupported JSON schema type: " + type);
}
}
/**
* Builds a property representing an array-items type, using the "items" field, and if the "items" field is missing,
* constructs a property of type "any".
*
* @param schema JSON schema
* @return Property representing array-items type
*/
private Property buildItemsProperty(final JsonValue schema) {
if (!schema.isDefined("items")) {
final LocalizableObjectProperty property = new LocalizableObjectProperty();
property.setType("any");
return property;
}
final JsonValue items = schema.get("items");
if (items.isNull()) {
throw new TransformerException("JSON-array 'items' field cannot be null: " + schema);
}
return buildProperty(items);
}
/**
* Reads an array of JSON strings, given a field name.
*
* @param field Field name
* @param schema Schema
* @return result or empty-list, if field is undefined or value is {@code null}
*/
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();
}
/**
* Swagger 2.0 does not support a JSON Schema type field that is an array of types, nor does it support the
* "null" type, so this method makes a best effort to choose a single type under all circumstances. When there
* are multiple types to choose from, other than "null", the type "any" will be returned.
*
* @param schema Schema
* @return A single JSON Schema type
*/
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();
}
/**
* Determines whether or not an "id" field is safe to use for JSON References.
*
* @param schema Schema
* @return {@code true} if schema as an "id" field is safe to use for JSON References and {@code false} otherwise
*/
private boolean hasReferenceableId(final JsonValue schema) {
return isReferenceableId(schema.get("id").asString());
}
/**
* Determines whether or not an ID string is safe to use for JSON References.
*
* @param id ID
* @return {@code true} if ID is safe to use for JSON References and {@code false} otherwise
*/
private boolean isReferenceableId(final String id) {
return id != null && (id.startsWith(URN_JSONSCHEMA_PREFIX) || id.startsWith(FRAPI_PREFIX));
}
/**
* Registers a JSON Schema in <em>definitions</em>, so that it can later be referred to using JSON Reference,
* but only if it has an {@code id} field that {@link #isReferenceableId(String)}.
*
* @param schema Schema
* @param model Model for schema
* @return Definition-name if schema was added to definitions or {@code null} otherwise
*/
@VisibleForTesting
String addDefinitionReference(final JsonValue schema, final Model model) {
if (hasReferenceableId(schema)) {
final String id = schema.get("id").asString();
final Model existingModel = definitionMap.put(id, model);
if (existingModel != null && !existingModel.equals(model)) {
logger.info("Replacing schema definition with id: " + id);
}
return id;
}
return null;
}
/**
* Locates a JSON reference segment from an API Descriptor JSON reference, and strips everything before the
* name of the reference under <em>definitions</em>.
*
* @param reference API Descriptor JSON reference
* @return JSON reference segment or {@code null}
*/
@VisibleForTesting
String getDefinitionsReference(final Reference reference) {
if (reference != null) {
return getDefinitionsReference(reference.getValue());
}
return null;
}
/**
* Locates a JSON reference segment from an API Descriptor JSON reference, and strips everything before the
* name of the reference under <em>definitions</em>.
*
* @param reference API Descriptor JSON reference-value
* @return JSON reference segment or {@code null}
*/
@VisibleForTesting
String getDefinitionsReference(final String reference) {
if (!isEmpty(reference)) {
if (isReferenceableId(reference)) {
return reference;
}
final int start = reference.indexOf(RefType.DEFINITION.getInternalPrefix());
if (start != -1) {
final String s = reference.substring(start + RefType.DEFINITION.getInternalPrefix().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());
}
}