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

de.sonallux.spotify.generator.openapi.OpenApiGenerator Maven / Gradle / Ivy

package de.sonallux.spotify.generator.openapi;

import com.google.common.base.Strings;
import de.sonallux.spotify.core.SpotifyWebApiUtils;
import de.sonallux.spotify.core.model.*;
import io.swagger.v3.oas.models.*;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.media.*;
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.security.*;
import io.swagger.v3.oas.models.servers.Server;
import io.swagger.v3.oas.models.tags.Tag;
import lombok.extern.slf4j.Slf4j;

import java.util.*;
import java.util.regex.Matcher;
import java.util.stream.Collectors;

import static de.sonallux.spotify.core.model.SpotifyWebApiEndpoint.ParameterLocation.BODY;

@Slf4j
public class OpenApiGenerator {

    private static final String MEDIA_TYPE_JSON = "application/json";
    private static final String SPOTIFY_SECURITY_SCHEME = "spotify_auth";

    //TODO: possible response status codes: https://developer.spotify.com/documentation/web-api/#response-status-codes

    private OpenAPI openAPI;
    private final CloneHelper cloneHelper;

    public OpenApiGenerator() {
        this.cloneHelper = new CloneHelper();
    }

    public OpenAPI generate(SpotifyWebApi apiDocumentation) {
        this.openAPI = new OpenAPI();
        this.openAPI
                .externalDocs(generateExternalDocumentation(apiDocumentation.getApiDocumentationUrl()))
                .info(new Info()
                        .title("Spotify Web API")
                        .version(VersionProvider.getVersion())
                        .contact(new Contact()
                            .name("sonallux")
                            .url("https://github.com/sonallux/spotify-web-api")
                        )
                )
                .servers(List.of(new Server().url(apiDocumentation.getEndpointUrl())))
                .components(new Components()
                        .schemas(generateSchemaObjects(apiDocumentation.getObjectList()))
                        .addResponses("ErrorResponse", getDefaultErrorResponse())
                        .securitySchemes(Map.of(SPOTIFY_SECURITY_SCHEME, getSpotifySecurityScheme(apiDocumentation.getScopes())))
                )
                .tags(generateTags(apiDocumentation.getCategoryList()))
                .paths(generatePaths(apiDocumentation.getCategoryList()))
        ;
        return openAPI;
    }

    private SecurityScheme getSpotifySecurityScheme(SpotifyAuthorizationScopes scopes) {
        var openApiScopes = new Scopes();
        openApiScopes.putAll(scopes.getScopeList().stream().collect(Collectors
                .toMap(SpotifyScope::getId, SpotifyScope::getDescription)));

        return new SecurityScheme()
                .type(SecurityScheme.Type.OAUTH2)
                .flows(new OAuthFlows().authorizationCode(new OAuthFlow()
                        .authorizationUrl("https://accounts.spotify.com/authorize")
                        .tokenUrl("https://accounts.spotify.com/api/token")
                        .scopes(openApiScopes)
                ))
        ;
    }

    private Paths generatePaths(Collection categories) {
        var paths = new Paths();
        for (var category : categories) {
            for (var endpoint : category.getEndpointList()) {
                var path = paths.computeIfAbsent(endpoint.getPath(), s -> new PathItem());
                var operation = generateOperation(endpoint);
                operation.addTagsItem(category.getId());
                switch (endpoint.getHttpMethod()) {
                    case "GET":
                        path.setGet(operation);
                        break;
                    case "PUT":
                        path.setPut(operation);
                        break;
                    case "POST":
                        path.setPost(operation);
                        break;
                    case "DELETE":
                        path.setDelete(operation);
                        break;
                    default:
                        log.warn("Unknown http method at endpoint " + endpoint.getId() + ": " + endpoint.getHttpMethod());
                }
            }
        }
        return paths;
    }

    private Operation generateOperation(SpotifyWebApiEndpoint endpoint) {
        var parameters = endpoint.getParameters().stream()
                .map(this::generateParameter)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());

        var requestBody = generateRequestBody(endpoint);

        var apiResponses = new ApiResponses()._default(new ApiResponse().$ref("#/components/responses/ErrorResponse"));

        for (var responseType : endpoint.getResponseTypes()) {
            var response = getApiResponse(responseType);
            if (response != null) {
                if (response.getDescription() == null || response.getDescription().isEmpty()) {
                    response.description(endpoint.getResponseDescription());
                }
                apiResponses.put(String.valueOf(responseType.getStatus()), response);
            }
        }

        return new Operation()
                .operationId(endpoint.getId())
                .summary(endpoint.getName())
                .description(endpoint.getDescription())
                .externalDocs(generateExternalDocumentation(endpoint.getLink()))
                .parameters(parameters)
                .requestBody(requestBody)
                .responses(apiResponses)
                .security(List.of(new SecurityRequirement().addList(SPOTIFY_SECURITY_SCHEME, endpoint.getScopes())))
        ;
    }

    private ApiResponse getApiResponse(SpotifyWebApiEndpoint.ResponseType responseType) {
        var response = new ApiResponse().description(responseType.getDescription());
        if ("Void".equals(responseType.getType())) {
            return response;
        }

        var responseSchema = getSchema(responseType.getType(), openAPI.getComponents().getSchemas());
        if (responseSchema == null) {
            return null;
        }
        return response.content(new Content().addMediaType(MEDIA_TYPE_JSON, new MediaType().schema(responseSchema)));
    }

    private io.swagger.v3.oas.models.parameters.Parameter generateParameter(SpotifyWebApiEndpoint.Parameter param) {
        var parameter = new io.swagger.v3.oas.models.parameters.Parameter()
                .name(param.getName())
                .description(param.getDescription())
                .required(param.isRequired())
                .schema(getSchema(param.getType(), Map.of()));
        switch (param.getLocation()) {
            case HEADER:
                parameter.in("header");
                break;
            case PATH:
                parameter.in("path");
                break;
            case QUERY:
                parameter.in("query");
                break;
            case BODY:
                //Body parameter can not be mapped to io.swagger.v3.oas.models.parameters.Parameter
                //and are handled separately.
                return null;
            default:
                log.warn("Parameter " + param.getName() + " has unknown location: " + param.getLocation());
                return null;
        }
        return parameter;
    }

    public RequestBody generateRequestBody(SpotifyWebApiEndpoint endpoint) {
        var bodyParams = endpoint.getParameters().stream()
                .filter(p -> p.getLocation() == BODY)
                .collect(Collectors.toList());
        if (bodyParams.isEmpty()) {
            return null;
        }
        var requiredProps = new ArrayList();
        var props = new LinkedHashMap();
        for (var param : bodyParams) {
            var paramSchema = getSchema(param.getType(), Map.of());
            if (paramSchema == null) {
                continue;
            }
            paramSchema.description(param.getDescription());
            props.put(param.getName(), paramSchema);
            if (param.isRequired()) {
                requiredProps.add(param.getName());
            }
        }
        var schema = new ObjectSchema().properties(props);
        if (requiredProps.size() != 0) {
            schema.required(requiredProps);
        }
        return new RequestBody()
                .content(new Content().addMediaType(MEDIA_TYPE_JSON, new MediaType().schema(schema)))
                .required(requiredProps.size() != 0);
    }

    public ApiResponse getDefaultErrorResponse() {
        return new ApiResponse()
                .description("Unexpected error")
                .content(new Content()
                        .addMediaType(MEDIA_TYPE_JSON, new MediaType()
                                .schema(new Schema().$ref("#/components/schemas/ErrorResponseObject"))));
    }

    public List generateTags(Collection categories) {
        return categories.stream()
                .map(c -> new Tag()
                        .name(c.getId())
                        .description(c.getName())
                        .externalDocs(generateExternalDocumentation(c.getLink())))
                .sorted(Comparator.comparing(Tag::getName))
                .collect(Collectors.toList());
    }

    private ExternalDocumentation generateExternalDocumentation(String link) {
        return new ExternalDocumentation()
                .url(link)
                .description("Find more info on the official Spotify Web API Reference");
    }

    private Map generateSchemaObjects(Collection objects) {
        var schemas = new LinkedHashMap();

        SpotifyWebApiObject pagingObject = null;
        SpotifyWebApiObject cursorPagingObject = null;

        //First pass just adds empty objects, so we can add references
        for (var object : objects) {
            if ("PagingObject".equals(object.getName())) {
                pagingObject = object;
            } else if ("CursorPagingObject".equals(object.getName())) {
                cursorPagingObject = object;
            }
            var objectSchema = new ObjectSchema();
            if (!Strings.isNullOrEmpty(object.getLink())) {
                objectSchema.externalDocs(generateExternalDocumentation(object.getLink()));
            }
            schemas.put(object.getName(), objectSchema);
        }

        if (pagingObject == null || cursorPagingObject == null) {
            log.warn("Can not find PagingObject or CursorPagingObject");
        } else {
            //Generate properties for PagingObject and CursorPagingObject first, as they are needed to generate the
            //generic types (e.g. PagingCursor[TrackObject])
            schemas.get(pagingObject.getName()).properties(generateObjectProperties(pagingObject, schemas));
            schemas.get(cursorPagingObject.getName()).properties(generateObjectProperties(cursorPagingObject, schemas));
        }

        for (var object : objects) {
            schemas.get(object.getName()).properties(generateObjectProperties(object, schemas));
        }
        return schemas;
    }

    private Map generateObjectProperties(SpotifyWebApiObject object, Map customSchemas) {
        var properties = new LinkedHashMap();
        for (var prop : object.getProperties()) {
            var schema = getSchema(prop.getType(), customSchemas);
            if (schema == null) {
                continue;
            }
            schema.description(prop.getDescription());
            properties.put(prop.getName(), schema);
        }
        return properties;
    }

    /**
     * Maps the given type to a {@link Schema} object. Other objects that can be referenced by the given type
     * (e.g. Array[OtherObject]) can be specified via customSchemas. The type
     * Void is mapped to null.
     *
     * @param type the type to map
     * @param customSchemas schemas that can be referenced
     * @return the schema
     */
    private Schema getSchema(String type, Map customSchemas) {
        Matcher matcher;
        if ("String".equals(type)) {
            return new StringSchema();
        } else if ("Integer".equals(type)) {
            return new IntegerSchema();
        } else if ("Float".equals(type) || "Number".equals(type)) {
            return new NumberSchema();
        } else if ("Boolean".equals(type)) {
            return new BooleanSchema();
        } else if ("Timestamp".equals(type)) {
            //TODO: Check if DateTimeSchema is ok
            return new DateTimeSchema();
        } else if ("Object".equals(type)) {
            return new ObjectSchema();
        } else if ("Void".equals(type)) {
            return null;
        } else if (customSchemas.containsKey(type)) {
            return new Schema().$ref("#/components/schemas/" + type);
        } else if ((matcher = SpotifyWebApiUtils.ARRAY_TYPE_PATTERN.matcher(type)).matches()) {
            var arrayItemSchema = getSchema(matcher.group(1), customSchemas);
            return new ArraySchema()
                    .items(arrayItemSchema);
        } else if ((matcher = SpotifyWebApiUtils.PAGING_OBJECT_TYPE_PATTERN.matcher(type)).matches()) {
            var arrayItemSchema = getSchema(matcher.group(1), customSchemas);
            var pagingObjectSchema = cloneHelper.cloneSchema(customSchemas.get("PagingObject"));
            var pagingItemsSchema = (ArraySchema)pagingObjectSchema.getProperties().get("items");
            pagingItemsSchema.items(arrayItemSchema);
            return pagingObjectSchema;
        } else if ((matcher = SpotifyWebApiUtils.CURSOR_PAGING_OBJECT_TYPE_PATTERN.matcher(type)).matches()) {
            var arrayItemSchema = getSchema(matcher.group(1), customSchemas);
            var pagingObjectSchema = cloneHelper.cloneSchema(customSchemas.get("CursorPagingObject"));
            var pagingItemsSchema = (ArraySchema)pagingObjectSchema.getProperties().get("items");
            pagingItemsSchema.items(arrayItemSchema);
            return pagingObjectSchema;
        } else if (type.contains(" | ")) {
            var types = Arrays.stream(type.split(" \\| "))
                    .map(t -> getSchema(t, customSchemas))
                    .collect(Collectors.toList());
            return new ComposedSchema().oneOf(types);
        } else {
            throw new RuntimeException("Missing type: " + type);
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy