All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.yahoo.elide.swagger.OpenApiBuilder Maven / Gradle / Ivy

There is a newer version: 7.1.2
Show newest version
/*
 * Copyright 2016, Yahoo Inc.
 * Licensed under the Apache License, Version 2.0
 * See LICENSE file in project root for terms.
 */
package com.yahoo.elide.swagger;

import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION;

import com.yahoo.elide.annotation.CreatePermission;
import com.yahoo.elide.annotation.DeletePermission;
import com.yahoo.elide.annotation.Exclude;
import com.yahoo.elide.annotation.ReadPermission;
import com.yahoo.elide.annotation.UpdatePermission;

import com.yahoo.elide.core.dictionary.EntityDictionary;
import com.yahoo.elide.core.dictionary.RelationshipType;
import com.yahoo.elide.core.filter.Operator;
import com.yahoo.elide.core.security.checks.prefab.Role;
import com.yahoo.elide.core.type.ClassType;
import com.yahoo.elide.core.type.Type;
import com.yahoo.elide.jsonapi.JsonApi;
import com.yahoo.elide.swagger.converter.JsonApiModelResolver;
import com.yahoo.elide.swagger.models.media.AtomicOperations;
import com.yahoo.elide.swagger.models.media.AtomicResults;
import com.yahoo.elide.swagger.models.media.Data;
import com.yahoo.elide.swagger.models.media.Datum;
import com.yahoo.elide.swagger.models.media.Errors;
import com.yahoo.elide.swagger.models.media.Relationship;
import com.google.common.collect.Sets;

import org.antlr.v4.runtime.tree.ParseTree;
import org.apache.commons.lang3.tuple.Pair;

import io.swagger.v3.core.converter.AnnotatedType;
import io.swagger.v3.core.converter.ModelConverter;
import io.swagger.v3.core.converter.ModelConverterContextImpl;
import io.swagger.v3.core.converter.ModelConverters;
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.examples.Example;
import io.swagger.v3.oas.models.media.ArraySchema;
import io.swagger.v3.oas.models.media.Content;
import io.swagger.v3.oas.models.media.IntegerSchema;
import io.swagger.v3.oas.models.media.MediaType;
import io.swagger.v3.oas.models.media.StringSchema;
import io.swagger.v3.oas.models.parameters.Parameter;
import io.swagger.v3.oas.models.parameters.Parameter.StyleEnum;
import io.swagger.v3.oas.models.parameters.PathParameter;
import io.swagger.v3.oas.models.parameters.QueryParameter;
import io.swagger.v3.oas.models.parameters.RequestBody;
import io.swagger.v3.oas.models.responses.ApiResponse;
import io.swagger.v3.oas.models.responses.ApiResponses;
import io.swagger.v3.oas.models.tags.Tag;

import lombok.Getter;

import java.lang.annotation.Annotation;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.Stack;
import java.util.function.Consumer;
import java.util.stream.Collectors;

/**
 * Builds a 'OpenAPI' object from Swagger-Core by walking the static Elide
 * entity metadata contained in the 'EntityDictionary'. The 'OpenAPI' object can
 * be used to generate a OpenAPI document.
 */
public class OpenApiBuilder {
    protected EntityDictionary dictionary;
    protected Set> rootClasses;
    protected Set> managedClasses;
    protected OpenAPI openApi;
    protected Map globalResponses;
    protected Set globalParameters;
    protected Set filterOperators;
    protected boolean supportLegacyFilterDialect;
    protected boolean supportRSQLFilterDialect;
    protected String apiVersion = NO_VERSION;
    protected String basePath = null;

    public static final ApiResponse UNAUTHORIZED_RESPONSE = new ApiResponse().description("Unauthorized");
    public static final ApiResponse FORBIDDEN_RESPONSE = new ApiResponse().description("Forbidden");
    public static final ApiResponse NOT_FOUND_RESPONSE = new ApiResponse().description("Not Found");
    public static final ApiResponse REQUEST_TIMEOUT_RESPONSE = new ApiResponse().description("Request Timeout");
    public static final ApiResponse TOO_MANY_REQUESTS_RESPONSE = new ApiResponse().description("Too Many Requests");

    /**
     * Metadata for constructing URLs and OpenAPI 'Path' objects.
     */
    public class PathMetaData {

        /**
         * All prior nodes in the path to the root entity.
         */
        private Stack lineage;

        /**
         * Either the name of the root collection or the relationship.
         */
        @Getter
        private String name;

        /**
         * Either the type of the root collection of the relationship.
         */
        @Getter
        private Type type;

        /**
         * The unique identifying instance URL for the path.
         */
        @Getter
        private String url;

        /**
         * Constructs a PathMetaData for a 'root' entity.
         *
         * @param type the 'root' entity type of the first segment of the URL.
         */
        public PathMetaData(Type type) {
            this(new Stack<>(), dictionary.getJsonAliasFor(type), type);
        }

        /**
         * Required argument constructor.
         *
         * @param lineage The lineage of prior path elements.
         * @param name    The relationship of the path element.
         * @param type    The type associated with the relationship.
         */
        public PathMetaData(Stack lineage, String name, Type type) {
            this.lineage = lineage;
            this.type = type;
            this.name = name;
            this.url = constructInstanceUrl();
        }

        /**
         * Returns the root type (first collection) of this path.
         *
         * @return The class that represents the root collection of the path.
         */
        public Type getRootType() {
            if (lineage.isEmpty()) {
                return type;
            }

            return lineage.elementAt(0).type;
        }

        /**
         * Returns a URL that represents the collection.
         *
         * @return Something like '/book/{bookId}/authors' or '/publisher'
         */
        public String getCollectionUrl() {
            if (lineage.isEmpty()) {
                return "/" + name;
            }
            return lineage.peek().getUrl() + "/" + name;
        }

        /**
         * Constructs a URL that returns an instance of the entity.
         *
         * @return Something like '/book/{bookId}'
         */
        private String constructInstanceUrl() {
            String typeName = dictionary.getJsonAliasFor(type);
            return getCollectionUrl() + "/{" + typeName + "Id}";
        }

        /**
         * Constructs a URL that returns a relationship collection.
         *
         * @return Something like '/book/{bookId}/relationships/authors'
         * @throws IllegalStateException for errors.
         */
        public String getRelationshipUrl() {
            if (lineage.isEmpty()) {
                throw new IllegalStateException("Root collections don't have relationships");
            }

            PathMetaData prior = lineage.peek();
            String baseUrl = prior.getUrl();

            return baseUrl + "/relationships/" + name;
        }

        @Override
        public String toString() {
            return getUrl();
        }

        /**
         * All Paths are 'tagged' in swagger with the final entity type name in the
         * path. This allows swaggerUI to group the paths by entities.
         *
         * @return the entity type name
         */
        private String getTag() {
            return tagNameOf(type);
        }

        private List getTags() {
            return Collections.singletonList(getTag());
        }

        /**
         * Returns the path parameter for the instance URL.
         *
         * @return the swagger PathParameter for this particular path segment.
         */
        private Parameter getPathParameter() {
            String typeName = dictionary.getJsonAliasFor(type);

            return new PathParameter().name(typeName + "Id").description(typeName + " Identifier")
                    .schema(new StringSchema());
        }

        /**
         * Returns the OpenAPI path for a relationship URL.
         *
         * @return the OpenAPI 'Path' for a relationship URL
         *         (/books/{bookId}/relationships/author).
         * @throws IllegalStateException for errors.
         */
        public PathItem getRelationshipPath() {
            if (lineage.isEmpty()) {
                throw new IllegalStateException("Root collections don't have relationships");
            }

            PathItem path = new PathItem();

            /* The path parameter apply for all operations */
            lineage.stream().forEach(item -> path.addParametersItem(item.getPathParameter()));

            String schemaName = getSchemaName(type);

            ApiResponse okSingularResponse = new ApiResponse().description("Successful response").content(new Content()
                    .addMediaType(JsonApi.MEDIA_TYPE, new MediaType().schema(new Datum(new Relationship(schemaName)))));

            ApiResponse okPluralResponse = new ApiResponse().description("Successful response").content(new Content()
                    .addMediaType(JsonApi.MEDIA_TYPE, new MediaType().schema(new Data(new Relationship(schemaName)))));

            ApiResponse okEmptyResponse = new ApiResponse().description("Successful response");

            Type parentClass = lineage.peek().getType();
            RelationshipType relationshipType = dictionary.getRelationshipType(parentClass, name);

            if (relationshipType.isToMany()) {
                if (canRead(parentClass, name) && canRead(type)) {
                    path.get(new Operation().tags(getTags())
                            .description("Returns the relationship identifiers for " + name)
                            .responses(new ApiResponses().addApiResponse("200", okPluralResponse)));
                }

                if (canUpdate(parentClass, name)) {
                    path.post(new Operation().tags(getTags()).description("Adds items to the relationship " + name)
                            .requestBody(new RequestBody().content(new Content().addMediaType(JsonApi.MEDIA_TYPE,
                                    new MediaType().schema(new Data(new Relationship(schemaName))))))
                            .responses(new ApiResponses().addApiResponse("201", okPluralResponse)));

                    path.patch(new Operation().tags(getTags()).description("Replaces the relationship " + name)
                            .requestBody(new RequestBody().content(new Content().addMediaType(JsonApi.MEDIA_TYPE,
                                    new MediaType().schema(new Data(new Relationship(schemaName))))))
                            .responses(new ApiResponses().addApiResponse("204", okEmptyResponse)));

                    path.delete(new Operation().tags(getTags())
                            .description("Deletes items from the relationship " + name)
                            .requestBody(new RequestBody().content(new Content().addMediaType(JsonApi.MEDIA_TYPE,
                                    new MediaType().schema(new Data(new Relationship(schemaName))))))
                            .responses(new ApiResponses().addApiResponse("204", okEmptyResponse)));
                }
            } else {
                if (canRead(parentClass, name) && canRead(type)) {
                    path.get(new Operation().tags(getTags())
                            .description("Returns the relationship identifiers for " + name)
                            .responses(new ApiResponses().addApiResponse("200", okSingularResponse)));
                }

                if (canUpdate(parentClass, name)) {
                    path.patch(new Operation().tags(getTags()).description("Replaces the relationship " + name)
                            .requestBody(new RequestBody().content(new Content().addMediaType(JsonApi.MEDIA_TYPE,
                                    new MediaType().schema(new Datum(new Relationship(schemaName))))))
                            .responses(new ApiResponses().addApiResponse("204", okEmptyResponse)));
                }
            }

            if (path.getGet() != null) {
                for (Parameter param : getFilterParameters()) {
                    path.getGet().addParametersItem(param);
                }

                for (Parameter param : getPageParameters()) {
                    path.getGet().addParametersItem(param);
                }
            }

            decorateGlobalResponses(path);
            decorateGlobalParameters(path);
            return path;
        }

        /**
         * Returns the OpenAPI Path for a collection URL.
         *
         * @return the OpenAPI 'Path' for a collection URL (/books).
         */
        public PathItem getCollectionPath() {
            String typeName = dictionary.getJsonAliasFor(type);
            String schemaName = getSchemaName(type);
            PathItem path = new PathItem();

            /* The path parameter apply for all operations */
            lineage.stream().forEach(item -> path.addParametersItem(item.getPathParameter()));

            ApiResponse okSingularResponse = new ApiResponse().description("Successful response").content(
                    new Content().addMediaType(JsonApi.MEDIA_TYPE, new MediaType().schema(new Datum(schemaName))));

            ApiResponse okPluralResponse = new ApiResponse().description("Successful response").content(
                    new Content().addMediaType(JsonApi.MEDIA_TYPE, new MediaType().schema(new Data(schemaName))));

            String getDescription;
            String postDescription;
            boolean canPost = false;
            boolean canGet = false;
            if (lineage.isEmpty()) {
                getDescription = "Returns the collection of type " + typeName;
                postDescription = "Creates an item of type " + typeName;

                canGet = canRead(type);
                canPost = canCreate(type);
            } else {
                getDescription = "Returns the relationship " + name;
                postDescription = "Creates an item of type " + typeName + " and adds it to " + name;

                Type parentClass = lineage.peek().getType();
                canGet = canRead(parentClass, name) && canRead(type);
                canPost = canUpdate(parentClass, name) && canCreate(type);
            }

            List parameters = new ArrayList<>();
            parameters.add(getSortParameter());
            parameters.add(getSparseFieldsParameter());
            getIncludeParameter().ifPresent(parameters::add);

            if (canPost) {
                path.post(new Operation().tags(getTags()).description(postDescription)
                        .requestBody(new RequestBody().content(new Content().addMediaType(JsonApi.MEDIA_TYPE,
                                new MediaType().schema(new Datum(schemaName)))))
                        .responses(new ApiResponses().addApiResponse("201", okSingularResponse)));
            }

            if (canGet) {
                path.get(new Operation().tags(getTags()).description(getDescription).parameters(parameters)
                        .responses(new ApiResponses().addApiResponse("200", okPluralResponse)));

                for (Parameter param : getFilterParameters()) {
                    path.getGet().addParametersItem(param);
                }

                for (Parameter param : getPageParameters()) {
                    path.getGet().addParametersItem(param);
                }
            }

            decorateGlobalResponses(path);
            decorateGlobalParameters(path);
            return path;
        }

        /**
         * Returns the OpenAPI Path for an instance URL.
         *
         * @return the OpenAPI 'Path' for a instance URL (/books/{bookID}).
         */
        public PathItem getInstancePath() {
            String typeName = dictionary.getJsonAliasFor(type);
            String schemaName = getSchemaName(type);
            PathItem path = new PathItem();

            /* The path parameter apply for all operations */
            getFullLineage().stream().forEach(item -> path.addParametersItem(item.getPathParameter()));

            ApiResponse okSingularResponse = new ApiResponse().description("Successful response")
                    .content(new Content().addMediaType(JsonApi.MEDIA_TYPE,
                            new MediaType().schema(new com.yahoo.elide.swagger.models.media.Datum(schemaName))));

            ApiResponse okEmptyResponse = new ApiResponse().description("Successful response");

            List parameters = new ArrayList<>();
            parameters.add(getSparseFieldsParameter());
            getIncludeParameter().ifPresent(parameters::add);

            boolean canGet = false;
            boolean canPatch = false;
            boolean canDelete = false;

            if (lineage.isEmpty()) {
                // Root entity
                canGet = canReadById(type);
                canPatch = canUpdateById(type);
                canDelete = canDeleteById(type);
            } else {
                // Relationship
                Type parentClass = lineage.peek().getType();
                canGet = canRead(parentClass, name) && canReadById(type);
                canPatch = canUpdate(parentClass, name);
                canDelete = canUpdate(parentClass, name);
            }

            if (canGet) {
                path.get(new Operation().tags(getTags()).description("Returns an instance of type " + typeName)
                        .parameters(parameters)
                        .responses(new ApiResponses().addApiResponse("200", okSingularResponse)));
            }

            if (canPatch) {
                path.patch(new Operation().tags(getTags()).description("Modifies an instance of type " + typeName)
                        .requestBody(new RequestBody().content(new Content().addMediaType(JsonApi.MEDIA_TYPE,
                                new MediaType().schema(new Datum(schemaName)))))
                        .responses(new ApiResponses().addApiResponse("204", okEmptyResponse)));
            }

            if (canDelete) {
                path.delete(new Operation().tags(getTags()).description("Deletes an instance of type " + typeName)
                        .responses(new ApiResponses().addApiResponse("204", okEmptyResponse)));
            }

            decorateGlobalResponses(path);
            decorateGlobalParameters(path);
            return path;
        }

        /**
         * Decorates with path parameters that apply to all paths.
         *
         * @param path the path to decorate
         * @return the decorated path
         */
        private PathItem decorateGlobalParameters(PathItem path) {
            globalParameters.forEach(path::addParametersItem);
            return path;
        }

        /**
         * Decorates with responses that apply to all operations for all paths.
         *
         * @param path the path to decorate.
         * @return the decorated path.
         */
        private PathItem decorateGlobalResponses(PathItem path) {
            globalResponses.forEach((code, response) -> {
                if (path.getGet() != null) {
                    path.getGet().getResponses().addApiResponse(code, response);
                }
                if (path.getDelete() != null) {
                    path.getDelete().getResponses().addApiResponse(code, response);
                }
                if (path.getPost() != null) {
                    path.getPost().getResponses().addApiResponse(code, response);
                }
                if (path.getPatch() != null) {
                    path.getPatch().getResponses().addApiResponse(code, response);
                }
            });
            return path;
        }

        /**
         * Returns the sparse fields query parameter.
         *
         * @return the JSON-API 'field' query parameter for some GET operations.
         */
        private Parameter getSparseFieldsParameter() {
            String typeName = dictionary.getJsonAliasFor(type);
            List fieldNames = dictionary.getAllExposedFields(type);

            return new QueryParameter().schema(new ArraySchema().items(new StringSchema()._enum(fieldNames)))
                    .name("fields[" + typeName + "]")
                    .description("Selects the set of " + typeName + " fields that should be returned in the result.")
                    .style(StyleEnum.FORM).explode(false); // style form explode false is collection format csv
        }

        /**
         * Returns the include parameter.
         *
         * @return the JSON-API 'include' query parameter for some GET operations.
         */
        private Optional getIncludeParameter() {
            List relationshipNames = dictionary.getRelationships(type);
            if (relationshipNames.isEmpty()) {
                return Optional.empty();
            }
            return Optional.of(new QueryParameter()
                    .schema(new ArraySchema().items(new StringSchema()._enum(relationshipNames))).name("include")
                    .description("Selects the set of relationships that should be expanded as a compound document in "
                            + "the result.")
                    .style(StyleEnum.FORM).explode(false)); // style form explode false is collection format csv
        }

        /**
         * Returns the pagination parameter.
         *
         * @return the Elide 'page' query parameter for some GET operations.
         */
        private List getPageParameters() {
            List params = new ArrayList<>();

            params.add(new QueryParameter().name("page[number]")
                    .description("Number of pages to return.  Can be used with page[size]")
                    .schema(new IntegerSchema()));

            params.add(new QueryParameter().name("page[size]")
                    .description("Number of elements per page.  Can be used with page[number]")
                    .schema(new IntegerSchema()));

            params.add(new QueryParameter().name("page[offset]")
                    .description("Offset from 0 to start paginating.  Can be used with page[limit]")
                    .schema(new IntegerSchema()));

            params.add(new QueryParameter().name("page[limit]")
                    .description("Maximum number of items to return.  Can be used with page[offset]")
                    .schema(new IntegerSchema()));

            params.add(new QueryParameter().name("page[totals]")
                    .description("For requesting total pages/records be included in the response page meta data")
                    /*
                     * Swagger UI doesn't support parameters that don't take args today. We'll just
                     * make this a string for now
                     */
                    .schema(new StringSchema()));

            return params;
        }

        /**
         * Returns the sort parameter.
         *
         * @return the JSON-API 'sort' query parameter for some GET operations.
         */
        private Parameter getSortParameter() {
            List filterAttributes = dictionary.getAttributes(type).stream().filter(name -> {
                Type attributeClass = dictionary.getType(type, name);
                return (attributeClass.isPrimitive() || ClassType.STRING_TYPE.isAssignableFrom(attributeClass));
            }).map(name -> Arrays.asList(name, "-" + name)).flatMap(Collection::stream).collect(Collectors.toList());

            filterAttributes.add("id");
            filterAttributes.add("-id");

            return new QueryParameter().name("sort")
                    .schema(new ArraySchema().items(new StringSchema()._enum(filterAttributes)))
                    .description("Sorts the collection on the selected attributes.  A prefix of '-' sorts descending")
                    .style(StyleEnum.FORM).explode(false); // style form explode false is collection format csv
        }

        /**
         * Returns the filter parameter.
         *
         * @return the Elide 'filter' query parameter for some GET operations.
         */
        private List getFilterParameters() {
            String typeName = dictionary.getJsonAliasFor(type);
            List attributeNames = dictionary.getAttributes(type);

            List params = new ArrayList<>();

            if (supportRSQLFilterDialect) {
                /* Add RSQL Disjoint Filter Query Param */
                params.add(new QueryParameter().schema(new StringSchema()).name("filter[" + typeName + "]")
                        .description("Filters the collection of " + typeName + " using a 'disjoint' RSQL expression"));

                if (lineage.isEmpty()) {
                    /* Add RSQL Joined Filter Query Param */
                    params.add(new QueryParameter().schema(new StringSchema()).name("filter").description(
                            "Filters the collection of " + typeName + " using a 'joined' RSQL expression"));
                }
            }

            if (supportLegacyFilterDialect) {
                for (Operator op : filterOperators) {
                    attributeNames.forEach(name -> {
                        Type attributeClass = dictionary.getType(type, name);

                        /* Only filter attributes that can be assigned to strings or primitives */
                        if (attributeClass.isPrimitive() || ClassType.STRING_TYPE.isAssignableFrom(attributeClass)) {
                            params.add(new QueryParameter().schema(new StringSchema())
                                    .name("filter[" + typeName + "." + name + "][" + op.getNotation() + "]")
                                    .description("Filters the collection of " + typeName + " by the attribute " + name
                                            + " " + "using the operator " + op.getNotation()));
                        }
                    });
                }
            }

            return params;
        }

        /**
         * Constructs a new lineage including the current path element.
         *
         * @return ALL of the path segments in the URL including this segment.
         */
        public Stack getFullLineage() {
            Stack fullLineage = new Stack<>();

            fullLineage.addAll(lineage);
            fullLineage.add(this);
            return fullLineage;
        }

        /**
         * Returns true if this path is a shorter path to the same entity than the given
         * path.
         *
         * @param compare The path to compare against.
         * @return is shorter or same
         */
        public boolean shorterThan(PathMetaData compare) {
            return url.split("/").length < compare.getUrl().split("/").length;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (!(o instanceof PathMetaData)) {
                return false;
            }

            PathMetaData that = (PathMetaData) o;

            return url.equals(that.getUrl());
        }

        @Override
        public int hashCode() {
            return Objects.hash(lineage, name, type);
        }

        /**
         * Checks if a given path segment is already within the URL/lineage (forming a
         * cycle).
         *
         * @param other the segment to search for.
         * @return true if the lineage contains the given segment. False otherwise.
         */
        private boolean lineageContainsType(PathMetaData other) {
            if (this.type.equals(other.type)) {
                return true;
            }

            if (lineage.isEmpty()) {
                return false;
            }

            for (PathMetaData compare : lineage) {
                if (compare.type.equals(other.type)) {
                    return true;
                }
            }

            return false;
        }
    }

    /**
     * Constructor.
     * 

* The customizer can be used to set the OpenAPI SpecVersion. * * @param dictionary The entity dictionary. * @param openApiCustomizer The OpenAPI customizer. */ public OpenApiBuilder(EntityDictionary dictionary, Consumer openApiCustomizer) { this.dictionary = dictionary; this.supportLegacyFilterDialect = true; this.supportRSQLFilterDialect = true; this.globalResponses = new HashMap<>(); this.globalParameters = new HashSet<>(); this.managedClasses = new HashSet<>(); this.filterOperators = Sets.newHashSet(Operator.IN, Operator.NOT, Operator.INFIX, Operator.PREFIX, Operator.POSTFIX, Operator.GE, Operator.GT, Operator.LE, Operator.LT, Operator.ISNULL, Operator.NOTNULL); this.openApi = new OpenAPI(); if (openApiCustomizer != null) { openApiCustomizer.accept(this.openApi); } } /** * Constructor. * * @param dictionary The entity dictionary. */ public OpenApiBuilder(EntityDictionary dictionary) { this(dictionary, null); } /** * Decorates every operation on every path with the given response. * * @param code The HTTP status code to associate with the response * @param response The global response to add to every operation * @return the builder */ public OpenApiBuilder globalResponse(String code, ApiResponse response) { this.globalResponses.put(code, response); return this; } /** * Turns on or off the legacy filter dialect. * * @param enableLegacyFilterDialect Whether or not to enable the legacy filter * dialect. * @return the builder */ public OpenApiBuilder supportLegacyFilterDialect(boolean enableLegacyFilterDialect) { this.supportLegacyFilterDialect = enableLegacyFilterDialect; return this; } /** * Turns on or off the RSQL filter dialect. * * @param enableRSQLFilterDialect Whether or not to enable the RSQL filter dialect. * @return the builder */ public OpenApiBuilder supportRSQLFilterDialect(boolean enableRSQLFilterDialect) { this.supportRSQLFilterDialect = enableRSQLFilterDialect; return this; } /** * Decorates every path with the given parameter. * * @param parameter the parameter to decorate * @return the builder */ public OpenApiBuilder globalParameter(Parameter parameter) { this.globalParameters.add(parameter); return this; } /** * The classes for which API paths will be generated. All paths that include * other entities are dropped. * * @param classes A subset of the entities in the entity dictionary. * @return the builder */ public OpenApiBuilder managedClasses(Set> classes) { this.managedClasses = new LinkedHashSet<>(classes); return this; } /** * Assigns a subset of the complete set of filter operations to support for each * GET operation. * * @param ops The subset of filter operations to support. * @return the builder */ public OpenApiBuilder filterOperators(Set ops) { this.filterOperators = new LinkedHashSet<>(ops); return this; } /** * Customize the set of filter operations to support for each * GET operation. * * @param customizer the customizer * @return */ public OpenApiBuilder filterOperators(Consumer> customizer) { customizer.accept(this.filterOperators); return this; } /** * Sets the version of the API that is to be documented. * * @param apiVersion version of the API * @return */ public OpenApiBuilder apiVersion(String apiVersion) { this.apiVersion = apiVersion; if (this.apiVersion == null) { this.apiVersion = NO_VERSION; } return this; } /** * Builds a OpenAPI object. * @param openApi Apply configuration on 'OpenAPI' object * @return the builder */ public OpenApiBuilder applyTo(OpenAPI openApi) { if (managedClasses.isEmpty()) { managedClasses = dictionary.getBoundClassesByVersion(this.apiVersion); } else { managedClasses = Sets.intersection(dictionary.getBoundClassesByVersion(this.apiVersion), managedClasses); if (managedClasses.isEmpty()) { throw new IllegalArgumentException("None of the provided classes are exported by Elide"); } } managedClasses = managedClasses.stream() .sorted((left, right) -> left.getSimpleName().compareTo(right.getSimpleName())) .collect(Collectors.toCollection(LinkedHashSet::new)); /* * Create a Model for each Elide entity. * Elide entity could be of ClassType or DynamicType. * For ClassType, extract the class and pass it to ModelConverters#readAll method. * ModelConverters#readAll doesn't support Elide Dynamic Type, so calling the * JsonApiResolver#resolve method directly when its not a ClassType. */ ModelConverters converters = ModelConverters.getInstance(); ModelConverter converter = new JsonApiModelResolver(this.dictionary); converters.addConverter(converter); for (Type clazz : managedClasses) { if (clazz instanceof ClassType classType) { converters.readAll(classType.getCls()).forEach(openApi::schema); } else { ModelConverterContextImpl context = new ModelConverterContextImpl(Arrays.asList(converter)); context.resolve(new AnnotatedType().type(clazz)); context.getDefinedModels().forEach(openApi::schema); } } rootClasses = managedClasses.stream() .filter(dictionary::isRoot) .collect(Collectors.toCollection(LinkedHashSet::new)); /* Find all the paths starting from the root entities. */ Set pathData = rootClasses.stream() .map(this::find) .flatMap(Collection::stream) .collect(Collectors.toCollection(LinkedHashSet::new)); /* Prune the discovered paths to remove redundant elements */ Set toRemove = new HashSet<>(); pathData.stream() .collect(Collectors.groupingBy(p -> Pair.of(p.getType(), p.getName()))) .values() .forEach(pathSet -> { for (PathMetaData path : pathSet) { for (PathMetaData compare : pathSet) { /* * We don't prune paths that are redundant with root collections to allow both BOTH * root collection urls as well as relationship urls. */ if (compare.lineage.isEmpty() || path == compare) { continue; } /* * Find the unique shortest path to the node. */ if (compare.shorterThan(path)) { toRemove.add(path); break; } } } } ); pathData = Sets.difference(pathData, toRemove); /* Each path constructs 3 URLs (collection, instance, and relationship) */ for (PathMetaData pathDatum : pathData) { openApi.path(pathOf(pathDatum.getCollectionUrl()), pathDatum.getCollectionPath()); openApi.path(pathOf(pathDatum.getUrl()), pathDatum.getInstancePath()); /* We only construct relationship URLs if the entity is not a root collection */ if (! pathDatum.lineage.isEmpty()) { openApi.path(pathOf(pathDatum.getRelationshipUrl()), pathDatum.getRelationshipPath()); } } /* We create OpenAPI 'tags' for each entity so Swagger UI organizes the paths by entities */ managedClasses.stream() .map(type -> new Tag().name(tagNameOf(type)) .description(EntityDictionary.getEntityDescription(type))) .forEach(openApi::addTagsItem); /* Atomic operations */ atomicOperations(openApi); return this; } /** * Adds the operations path for JSON API atomic operations. * * @param openApi the open api */ protected void atomicOperations(OpenAPI openApi) { String tagName = tagNameOf("atomic"); openApi.addTagsItem(new Tag().name(tagName).description("Atomic operations.")); Map examples = atomicOperationsExamples(); Map example = null; AtomicOperations atomicOperations = new AtomicOperations(); // Try to determine more specific examples for atomic operations Optional> optionalCanCreateType = this.rootClasses.stream().filter(this::canCreate).findFirst(); if (optionalCanCreateType.isPresent()) { Type type = optionalCanCreateType.get(); String typeName = dictionary.getJsonAliasFor(type); Map attributes = dataAttributes(type); Map creatingResourcesData = new LinkedHashMap<>(); creatingResourcesData.put("type", typeName); creatingResourcesData.put("lid", "string"); creatingResourcesData.put("attributes", attributes); Map creatingResourcesOp = new LinkedHashMap<>(); creatingResourcesOp.put("op", "add"); creatingResourcesOp.put("href", "/" + typeName); creatingResourcesOp.put("data", creatingResourcesData); creatingResourcesOp.put("meta", new LinkedHashMap<>()); Map creatingResources = Map.of("atomic:operations", List.of(creatingResourcesOp)); example = creatingResources; // Replace generic example examples.put("Creating Resources", new Example().value(creatingResources).description(CREATING_RESOURCES_DESCRIPTION)); } Optional> optionalCanUpdateType = this.rootClasses.stream().filter(this::canUpdate).findFirst(); if (optionalCanUpdateType.isPresent()) { Type type = optionalCanUpdateType.get(); String typeName = dictionary.getJsonAliasFor(type); Map attributes = dataAttributes(type); Map updatingResourcesData = new LinkedHashMap<>(); updatingResourcesData.put("type", typeName); updatingResourcesData.put("id", "string"); updatingResourcesData.put("attributes", attributes); Map updatingResourcesOp = new LinkedHashMap<>(); updatingResourcesOp.put("op", "update"); updatingResourcesOp.put("data", updatingResourcesData); updatingResourcesOp.put("meta", new LinkedHashMap<>()); Map updatingResources = Map.of("atomic:operations", List.of(updatingResourcesOp)); if (example == null) { example = updatingResources; } // Replace generic example examples.put("Updating Resources", new Example().value(updatingResources).description(UPDATING_RESOURCES_DESCRIPTION)); } Optional> optionalCanDeleteType = this.rootClasses.stream().filter(this::canDelete).findFirst(); if (optionalCanDeleteType.isPresent()) { Type type = optionalCanDeleteType.get(); String typeName = dictionary.getJsonAliasFor(type); Map deletingResourcesRef = new LinkedHashMap<>(); deletingResourcesRef.put("type", typeName); deletingResourcesRef.put("id", "string"); Map deletingResourcesOp = new LinkedHashMap<>(); deletingResourcesOp.put("op", "remove"); deletingResourcesOp.put("ref", deletingResourcesRef); Map deletingResources = Map.of("atomic:operations", List.of(deletingResourcesOp)); if (example == null) { example = deletingResources; } // Replace generic example examples.put("Deleting Resources", new Example().value(deletingResources).description(DELETING_RESOURCES_DESCRIPTION)); } // Must call setExample() and cannot call example() as it calls toString on the // example if (example != null) { atomicOperations.setExample(example); } // Create that path for /operations PathItem operations = new PathItem(); operations.post(new Operation().tags(List.of(tagName)) .description("Perform atomic operations") .requestBody(new RequestBody().content(new Content().addMediaType(JsonApi.AtomicOperations.MEDIA_TYPE, new MediaType().schema(atomicOperations).examples(examples)))) .responses(new ApiResponses() .addApiResponse("200", new ApiResponse().description("Successful response") .content(new Content().addMediaType(JsonApi.AtomicOperations.MEDIA_TYPE, new MediaType().schema(new AtomicResults())))) .addApiResponse("400", new ApiResponse().description("Bad request") .content(new Content().addMediaType(JsonApi.AtomicOperations.MEDIA_TYPE, new MediaType().schema(new Errors())))) .addApiResponse("423", new ApiResponse().description("Locked") .content(new Content().addMediaType(JsonApi.AtomicOperations.MEDIA_TYPE, new MediaType().schema(new Errors())))))); openApi.path(pathOf("/operations"), operations); } protected Map dataAttributes(Type type) { Map attributes = new LinkedHashMap<>(); List attributeNames = dictionary.getAttributes(type); for (String attributeName : attributeNames) { Type attributeClass = dictionary.getType(type, attributeName); if (ClassType.STRING_TYPE.isAssignableFrom(attributeClass)) { attributes.put(attributeName, "string"); } else if (ClassType.NUMBER_TYPE.isAssignableFrom(attributeClass)) { attributes.put(attributeName, 0); } else if (ClassType.BOOLEAN_TYPE.isAssignableFrom(attributeClass)) { attributes.put(attributeName, true); } } return attributes; } protected String tagNameOf(Type type) { String tagName = dictionary.getJsonAliasFor(type); return tagNameOf(tagName); } protected String tagNameOf(String tagName) { if (!EntityDictionary.NO_VERSION.equals(apiVersion)) { tagName = "v" + apiVersion + "/" + tagName; } return tagName; } protected String pathOf(String url) { if (basePath == null || "/".equals(basePath)) { return url; } return basePath + url; } public OpenApiBuilder basePath(String basePath) { this.basePath = basePath; return this; } /** * Builds a OpenAPI object. * @return the constructed 'OpenAPI' object */ public OpenAPI build() { applyTo(this.openApi); return this.openApi; } /** * Finds all the paths by navigating the entity relationship graph - starting at * the given root entity. Cycles are avoided. * * @param rootClass the starting node of the graph * @return set of discovered paths. */ protected Set find(Type rootClass) { Queue toVisit = new ArrayDeque<>(); Set paths = new LinkedHashSet<>(); toVisit.add(new PathMetaData(rootClass)); while (!toVisit.isEmpty()) { PathMetaData current = toVisit.remove(); List relationshipNames; try { relationshipNames = dictionary.getRelationships(current.getType()); /* If the entity is not bound in the dictionary, skip it */ } catch (IllegalArgumentException e) { continue; } for (String relationshipName : relationshipNames) { Type relationshipClass = dictionary.getParameterizedType(current.getType(), relationshipName); PathMetaData next = new PathMetaData(current.getFullLineage(), relationshipName, relationshipClass); /* * We don't allow cycles AND we only record paths that traverse through the * provided subgraph */ if (current.lineageContainsType(next) || !managedClasses.contains(relationshipClass)) { continue; } toVisit.add(next); } paths.add(current); } return paths; } protected String getSchemaName(Type type) { // Should be the same as JsonApiModelResolver#getSchemaName String schemaName = dictionary.getJsonAliasFor(type); String apiVersion = EntityDictionary.getModelVersion(type); if (!EntityDictionary.NO_VERSION.equals(apiVersion)) { schemaName = "v" + this.apiVersion + "_" + schemaName; } return schemaName; } protected boolean isNone(String permission) { return "Prefab.Role.None".equalsIgnoreCase(permission) || Role.NONE_ROLE.equalsIgnoreCase(permission); } protected boolean canCreate(Type type) { return !isNone(getCreatePermission(type)); } protected boolean canRead(Type type) { return !isNone(getReadPermission(type)); } protected boolean canUpdate(Type type) { return !isNone(getUpdatePermission(type)); } protected boolean canDelete(Type type) { return !isNone(getDeletePermission(type)); } protected boolean canReadById(Type type) { boolean excluded = dictionary.getIdAnnotation(type, Exclude.class) != null; return !(isNone(getReadPermission(type)) || excluded); } protected boolean canUpdateById(Type type) { boolean excluded = dictionary.getIdAnnotation(type, Exclude.class) != null; return !(isNone(getUpdatePermission(type)) || excluded); } protected boolean canDeleteById(Type type) { boolean excluded = dictionary.getIdAnnotation(type, Exclude.class) != null; return !(isNone(getDeletePermission(type)) || excluded); } protected boolean canCreate(Type type, String field) { return !isNone(getCreatePermission(type, field)); } protected boolean canRead(Type type, String field) { return !isNone(getReadPermission(type, field)); } protected boolean canUpdate(Type type, String field) { return !isNone(getUpdatePermission(type, field)); } protected boolean canDelete(Type type, String field) { return !isNone(getDeletePermission(type, field)); } /** * Get the calculated {@link CreatePermission} value for the entity. * * @param clazz the entity class * @return the create permissions for an entity */ protected String getCreatePermission(Type clazz) { return getPermission(clazz, CreatePermission.class); } /** * Get the calculated {@link ReadPermission} value for the entity. * * @param clazz the entity class * @return the read permissions for an entity */ protected String getReadPermission(Type clazz) { return getPermission(clazz, ReadPermission.class); } /** * Get the calculated {@link UpdatePermission} value for the entity. * * @param clazz the entity class * @return the update permissions for an entity */ protected String getUpdatePermission(Type clazz) { return getPermission(clazz, UpdatePermission.class); } /** * Get the calculated {@link DeletePermission} value for the entity. * * @param clazz the entity class * @return the delete permissions for an entity */ protected String getDeletePermission(Type clazz) { return getPermission(clazz, DeletePermission.class); } /** * Get the calculated {@link CreatePermission} value for the relationship. * * @param clazz the entity class * @param field the field to inspect * @return the create permissions for the relationship */ protected String getCreatePermission(Type clazz, String field) { return getPermission(clazz, field, CreatePermission.class); } /** * Get the calculated {@link ReadPermission} value for the relationship. * * @param clazz the entity class * @param field the field to inspect * @return the read permissions for the relationship */ protected String getReadPermission(Type clazz, String field) { return getPermission(clazz, field, ReadPermission.class); } /** * Get the calculated {@link UpdatePermission} value for the relationship. * * @param clazz the entity class * @param field the field to inspect * @return the update permissions for the relationship */ protected String getUpdatePermission(Type clazz, String field) { return getPermission(clazz, field, UpdatePermission.class); } /** * Get the calculated {@link DeletePermission} value for the relationship. * * @param clazz the entity class * @param field the field to inspect * @return the delete permissions for the relationship */ protected String getDeletePermission(Type clazz, String field) { return getPermission(clazz, field, DeletePermission.class); } protected String getPermission(Type clazz, Class permission) { ParseTree parseTree = dictionary.getPermissionsForClass(clazz, permission); if (parseTree != null) { return parseTree.getText(); } return null; } protected String getPermission(Type clazz, String field, Class permission) { ParseTree parseTree = dictionary.getPermissionsForField(clazz, field, permission); if (parseTree != null) { return parseTree.getText(); } return null; } protected Map atomicOperationsExamples() { Map examples = new LinkedHashMap<>(); // Creating Resources Map creatingResources = exampleCreatingResources(); examples.put("Creating Resources", new Example().value(creatingResources).description(CREATING_RESOURCES_DESCRIPTION)); // Updating Resources Map updatingResources = exampleUpdatingResources(); examples.put("Updating Resources", new Example().value(updatingResources).description(UPDATING_RESOURCES_DESCRIPTION)); // Deleting Resources Map deletingResources = exampleDeletingResources(); examples.put("Deleting Resources", new Example().value(deletingResources).description(DELETING_RESOURCES_DESCRIPTION)); // Updating To-One Relationships Map updatingToOneRelationships = exampleUpdatingToOneRelationships(); examples.put("Updating To-One Relationships", new Example().value(updatingToOneRelationships).description(UPDATING_TO_ONE_RELATIONSHIPS_DESCRIPTION)); // Deleting To-One Relationships Map deletingToOneRelationships = exampleDeletingToOneRelationships(); examples.put("Deleting To-One Relationships", new Example().value(deletingToOneRelationships).description(DELETING_TO_ONE_RELATIONSHIPS_DESCRIPTION)); // Creating To-Many Relationships Map creatingToManyRelationships = exampleCreatingToManyRelationships(); examples.put("Creating To-Many Relationships", new Example().value(creatingToManyRelationships) .description(CREATING_TO_MANY_RELATIONSHIPS_DESCRIPTION)); // Updating To-Many Relationships Map updatingToManyRelationships = exampleUpdatingToManyRelationships(); examples.put("Updating To-Many Relationships", new Example().value(updatingToManyRelationships) .description(UPDATING_TO_MANY_RELATIONSHIPS_DESCRIPTION)); // Deleting To-Many Relationships Map deletingToManyRelationships = exampleDeletingToManyRelationships(); examples.put("Deleting To-Many Relationships", new Example().value(deletingToManyRelationships) .description(DELETING_TO_MANY_RELATIONSHIPS_DESCRIPTION)); return examples; } protected Map exampleDeletingToManyRelationships() { Map deletingToManyRelationshipsOp = new LinkedHashMap<>(); Map deletingToManyRelationshipsRef = new LinkedHashMap<>(); deletingToManyRelationshipsRef.put("type", "articles"); deletingToManyRelationshipsRef.put("id", "1"); deletingToManyRelationshipsRef.put("relationship", "comments"); Map deletingToManyRelationshipsData1 = new LinkedHashMap<>(); deletingToManyRelationshipsData1.put("type", "comments"); deletingToManyRelationshipsData1.put("id", "12"); Map deletingToManyRelationshipsData2 = new LinkedHashMap<>(); deletingToManyRelationshipsData2.put("type", "comments"); deletingToManyRelationshipsData2.put("id", "13"); deletingToManyRelationshipsOp.put("op", "remove"); deletingToManyRelationshipsOp.put("ref", deletingToManyRelationshipsRef); deletingToManyRelationshipsOp.put("data", List.of(deletingToManyRelationshipsData1, deletingToManyRelationshipsData2)); Map deletingToManyRelationships = Map.of("atomic:operations", List.of(deletingToManyRelationshipsOp)); return deletingToManyRelationships; } protected Map exampleUpdatingToManyRelationships() { Map updatingToManyRelationshipsOp = new LinkedHashMap<>(); Map updatingToManyRelationshipsRef = new LinkedHashMap<>(); updatingToManyRelationshipsRef.put("type", "articles"); updatingToManyRelationshipsRef.put("id", "1"); updatingToManyRelationshipsRef.put("relationship", "tags"); Map updatingToManyRelationshipsData1 = new LinkedHashMap<>(); updatingToManyRelationshipsData1.put("type", "tags"); updatingToManyRelationshipsData1.put("id", "2"); Map updatingToManyRelationshipsData2 = new LinkedHashMap<>(); updatingToManyRelationshipsData2.put("type", "tags"); updatingToManyRelationshipsData2.put("id", "3"); updatingToManyRelationshipsOp.put("op", "update"); updatingToManyRelationshipsOp.put("ref", updatingToManyRelationshipsRef); updatingToManyRelationshipsOp.put("data", List.of(updatingToManyRelationshipsData1, updatingToManyRelationshipsData2)); Map updatingToManyRelationships = Map.of("atomic:operations", List.of(updatingToManyRelationshipsOp)); return updatingToManyRelationships; } protected Map exampleCreatingToManyRelationships() { Map creatingToManyRelationshipsOp = new LinkedHashMap<>(); Map creatingToManyRelationshipsRef = new LinkedHashMap<>(); creatingToManyRelationshipsRef.put("type", "articles"); creatingToManyRelationshipsRef.put("id", "1"); creatingToManyRelationshipsRef.put("relationship", "comments"); Map creatingToManyRelationshipsData = new LinkedHashMap<>(); creatingToManyRelationshipsData.put("type", "comments"); creatingToManyRelationshipsData.put("id", "1"); creatingToManyRelationshipsOp.put("op", "add"); creatingToManyRelationshipsOp.put("ref", creatingToManyRelationshipsRef); creatingToManyRelationshipsOp.put("data", List.of(creatingToManyRelationshipsData)); Map creatingToManyRelationships = Map.of("atomic:operations", List.of(creatingToManyRelationshipsOp)); return creatingToManyRelationships; } protected Map exampleDeletingToOneRelationships() { Map deletingToOneRelationshipsOp = new LinkedHashMap<>(); Map deletingToOneRelationshipsRef = new LinkedHashMap<>(); deletingToOneRelationshipsRef.put("type", "articles"); deletingToOneRelationshipsRef.put("id", "13"); deletingToOneRelationshipsRef.put("relationship", "author"); deletingToOneRelationshipsOp.put("op", "update"); deletingToOneRelationshipsOp.put("ref", deletingToOneRelationshipsRef); deletingToOneRelationshipsOp.put("data", null); Map deletingToOneRelationships = Map.of("atomic:operations", List.of(deletingToOneRelationshipsOp)); return deletingToOneRelationships; } protected Map exampleUpdatingToOneRelationships() { Map updatingToOneRelationshipsOp = new LinkedHashMap<>(); Map updatingToOneRelationshipsRef = new LinkedHashMap<>(); updatingToOneRelationshipsRef.put("type", "articles"); updatingToOneRelationshipsRef.put("id", "13"); updatingToOneRelationshipsRef.put("relationship", "author"); Map updatingToOneRelationshipsData = new LinkedHashMap<>(); updatingToOneRelationshipsData.put("type", "people"); updatingToOneRelationshipsData.put("id", "9"); updatingToOneRelationshipsOp.put("op", "update"); updatingToOneRelationshipsOp.put("ref", updatingToOneRelationshipsRef); updatingToOneRelationshipsOp.put("data", updatingToOneRelationshipsData); Map updatingToOneRelationships = Map.of("atomic:operations", List.of(updatingToOneRelationshipsOp)); return updatingToOneRelationships; } protected Map exampleDeletingResources() { Map deletingResourcesOp = new LinkedHashMap<>(); Map deletingResourcesRef = new LinkedHashMap<>(); deletingResourcesRef.put("type", "articles"); deletingResourcesRef.put("id", "13"); deletingResourcesOp.put("op", "remove"); deletingResourcesOp.put("ref", deletingResourcesRef); Map deletingResources = Map.of("atomic:operations", List.of(deletingResourcesOp)); return deletingResources; } protected Map exampleUpdatingResources() { Map updatingResourcesOp = new LinkedHashMap<>(); Map updatingResourcesData = new LinkedHashMap<>(); updatingResourcesData.put("type", "articles"); updatingResourcesData.put("id", "13"); updatingResourcesData.put("attributes", Map.of("title", "Title")); updatingResourcesOp.put("op", "update"); updatingResourcesOp.put("data", updatingResourcesData); Map updatingResources = Map.of("atomic:operations", List.of(updatingResourcesOp)); return updatingResources; } protected Map exampleCreatingResources() { Map creatingResourcesOp = new LinkedHashMap<>(); Map creatingResourcesData = new LinkedHashMap<>(); creatingResourcesData.put("type", "articles"); creatingResourcesData.put("attributes", Map.of("title", "Title")); creatingResourcesOp.put("op", "add"); creatingResourcesOp.put("href", "/blogPosts"); creatingResourcesOp.put("data", creatingResourcesData); Map creatingResources = Map.of("atomic:operations", List.of(creatingResourcesOp)); return creatingResources; } private static final String CREATING_RESOURCES_DESCRIPTION = "" + "To create a resource, the operation MUST include an op code of \"add\" " + "as well as a resource object as data. " + "The resource object MUST contain at least a type member. An operation that creates a " + "resource MAY target a resource collection through the operation's href member."; private static final String UPDATING_RESOURCES_DESCRIPTION = "" + "To update a resource, the operation MUST include an op code of \"update\" " + "as well as a resource object as data. " + "An operation that updates a resource MAY target that resource through the " + "operation's ref or href members, but not both."; private static final String DELETING_RESOURCES_DESCRIPTION = "" + "To delete a resource, the operation MUST include an op code of \"remove\". " + "An operation that deletes a resource MUST target that resource through the " + "operation's ref or href members, but not both."; private static final String UPDATING_TO_ONE_RELATIONSHIPS_DESCRIPTION = "" + "To assign a to-one relationship, the operation MUST include an op code of \"update\" " + "as well as a resource identifier object as data. " + "An operation that updates a resource's to-one relationship " + "MUST target that relationship through the operation's ref or href members, but not both."; private static final String DELETING_TO_ONE_RELATIONSHIPS_DESCRIPTION = "" + "To clear a to-one relationship, the operation MUST include an op code of \"update\" " + "as well as setting the data to \"null\". " + "An operation that updates a resource's to-one relationship " + "MUST target that relationship through the operation's ref or href members, but not both."; private static final String CREATING_TO_MANY_RELATIONSHIPS_DESCRIPTION = "" + "To add members to a to-many relationship, the operation MUST include an op code of \"add\" " + "as well as an array of resource identifier objects as data." + " " + "An operation that updates a resource's to-many relationship MUST target that " + "relationship through the operation's ref or href members, but not both."; private static final String UPDATING_TO_MANY_RELATIONSHIPS_DESCRIPTION = "" + "To replace all the members of a to-many relationship, " + "the operation MUST include an op code of \"update\" " + "as well as an array of resource identifier objects as data. " + "An operation that updates a resource's to-many relationship MUST target that " + "relationship through the operation's ref or href members, but not both."; private static final String DELETING_TO_MANY_RELATIONSHIPS_DESCRIPTION = "" + "To remove members from a to-many relationship, the operation MUST include an op code of \"remove\" " + "as well as an array of resource identifier objects as data. " + "An operation that updates a resource's to-many relationship MUST target that " + "relationship through the operation's ref or href members, but not both."; }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy