plugin(3scale): merge api docs for the same service (#1249)

* fix(3scale): merge api docs for the same service
* fix(3scale): merge API docs titles and descriptions
* fix(3scale): fix title bug, improve tests
* fix(3scale): add changeset
* fix(3scale): remove console.log
* fix(3scale): add license headers
* fix(3scale): remove not working tsc command, use build instead of it
* fix(3scale): dedupe 3scale yarn.lock file

Signed-off-by: Oleksandr Andriienko <oandriie@redhat.com>
This commit is contained in:
Oleksandr Andriienko 2024-10-02 17:22:31 +03:00 committed by GitHub
parent 9328fa7ed0
commit d8c823820b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2431 additions and 49 deletions

View File

@ -0,0 +1,5 @@
---
'@backstage-community/plugin-3scale-backend': major
---
Merge API docs for the same service.

View File

@ -33,22 +33,27 @@
"postversion": "yarn run export-dynamic",
"prepack": "backstage-cli package prepack",
"start": "backstage-cli package start",
"test": "backstage-cli package test --passWithNoTests --coverage",
"tsc": "tsc"
"test": "backstage-cli package test --passWithNoTests --coverage"
},
"dependencies": {
"@backstage/backend-plugin-api": "^1.0.0",
"@backstage/catalog-model": "^1.7.0",
"@backstage/errors": "^1.2.4",
"@backstage/plugin-catalog-node": "^1.13.0"
"@backstage/plugin-catalog-node": "^1.13.0",
"atlassian-openapi": "^1.0.19",
"openapi-merge": "^1.3.3",
"swagger-converter": "2.1.0",
"swagger2openapi": "^7.0.4"
},
"devDependencies": {
"@backstage/backend-defaults": "^0.5.0",
"@backstage/backend-test-utils": "0.4.4",
"@backstage/cli": "^0.27.1",
"@backstage/config": "^1.2.0",
"@backstage/plugin-catalog-backend": "^1.26.0",
"@janus-idp/cli": "1.13.1",
"@types/supertest": "6.0.2",
"@types/swagger2openapi": "^7.0.4",
"msw": "1.3.3",
"supertest": "7.0.0"
},

View File

@ -0,0 +1,199 @@
{
"openapi": "3.0.0",
"info": {
"version": "1.0.0",
"title": "Echo API.",
"description": "A sample echo API."
},
"paths": {
"/": {
"get": {
"description": "Echo API with no parameters",
"operationId": "echo_no_params",
"parameters": [
{
"name": "user_key",
"in": "query",
"description": "Your API access key",
"required": true,
"x-data-threescale-name": "user_keys",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"text/xml": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
}
}
},
"default": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"text/xml": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
}
}
}
}
},
"/{echo}": {
"get": {
"description": "Echo API with parameters",
"operationId": "echo_with_params",
"parameters": [
{
"name": "echo",
"in": "path",
"description": "The string to be echoed",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "user_key",
"in": "query",
"description": "Your API access key",
"required": true,
"x-data-threescale-name": "user_keys",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"text/xml": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
}
}
},
"default": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"text/xml": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
}
}
}
}
}
},
"servers": [
{
"url": "http://echo-api.3scale.net/"
}
],
"components": {
"schemas": {
"ResponseModel": {
"type": "object",
"required": ["method", "path", "args", "headers"],
"properties": {
"method": {
"type": "string"
},
"path": {
"type": "string"
},
"args": {
"type": "string"
},
"headers": {
"type": "object"
}
}
},
"ErrorModel": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
}
}
}
}
}
}

View File

@ -0,0 +1,88 @@
{
"openapi": "3.0.0",
"info": {
"title": "Ping Service.",
"description": "A simple API that responds with the input message.",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com/v1",
"description": "Production Server"
},
{
"url": "https://api.staging.example.com/v1",
"description": "Staging Server"
}
],
"paths": {
"/ping": {
"post": {
"summary": "Ping message",
"description": "Returns the same message that was sent in the request body.",
"operationId": "pingMessage",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
},
"required": ["message"]
}
}
}
},
"responses": {
"200": {
"description": "The echoed message",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
}
}
}
}
},
"400": {
"description": "Invalid input, missing 'message' field"
}
}
}
}
},
"components": {
"schemas": {
"PingRequest": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
},
"required": ["message"]
},
"PingResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
}
}
}
}
}

View File

@ -0,0 +1,67 @@
{
"apiVersion": "1.2",
"swaggerVersion": "1.2",
"basePath": "https://api.example.com/v1",
"resourcePath": "/profile",
"apis": [
{
"path": "/profile/{userId}",
"description": "User profile operations",
"operations": [
{
"method": "GET",
"summary": "Get user profile by user ID",
"notes": "Returns a user's profile details by their ID.",
"type": "User",
"nickname": "getUserProfile",
"parameters": [
{
"name": "userId",
"description": "ID of the user to fetch",
"required": true,
"type": "string",
"paramType": "path"
}
],
"responseMessages": [
{
"code": 200,
"message": "Successful response",
"responseModel": "User"
},
{
"code": 404,
"message": "User not found"
},
{
"code": 500,
"message": "Server error"
}
]
}
]
}
],
"models": {
"User": {
"id": "User",
"properties": {
"id": {
"type": "string",
"description": "User ID",
"example": "1001"
},
"name": {
"type": "string",
"description": "User name",
"example": "John Doe"
},
"email": {
"type": "string",
"description": "User email address",
"example": "test@example.com"
}
}
}
}
}

View File

@ -0,0 +1,72 @@
{
"swagger": "2.0",
"info": {
"version": "1.0.0",
"title": "Simple API.",
"description": "List users API."
},
"host": "api.example.com",
"basePath": "/v1",
"schemes": ["https"],
"paths": {
"/users": {
"get": {
"summary": "Get all users",
"description": "Returns a list of users.",
"produces": ["application/json"],
"responses": {
"200": {
"description": "A list of users.",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/User"
}
}
}
}
}
},
"/users/{userId}": {
"get": {
"summary": "Get a user by ID",
"description": "Returns a single user.",
"parameters": [
{
"name": "userId",
"in": "path",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "A single user.",
"schema": {
"$ref": "#/definitions/User"
}
},
"404": {
"description": "User not found."
}
}
}
}
},
"definitions": {
"User": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
}
}
}

View File

@ -0,0 +1,268 @@
{
"openapi": "3.0.3",
"info": {
"title": "[Merged 2 API docs] Ping Service. Echo API.",
"description": "[Merged 2 API docs] A simple API that responds with the input message. A sample echo API.",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com/v1",
"description": "Production Server"
},
{
"url": "https://api.staging.example.com/v1",
"description": "Staging Server"
}
],
"paths": {
"/ping": {
"post": {
"summary": "Ping message",
"description": "Returns the same message that was sent in the request body.",
"operationId": "pingMessage",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
},
"required": ["message"]
}
}
}
},
"responses": {
"200": {
"description": "The echoed message",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
}
}
}
}
},
"400": {
"description": "Invalid input, missing 'message' field"
}
}
}
},
"/": {
"get": {
"description": "Echo API with no parameters",
"operationId": "echo_no_params",
"parameters": [
{
"name": "user_key",
"in": "query",
"description": "Your API access key",
"required": true,
"x-data-threescale-name": "user_keys",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"text/xml": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
}
}
},
"default": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"text/xml": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
}
}
}
}
},
"/{echo}": {
"get": {
"description": "Echo API with parameters",
"operationId": "echo_with_params",
"parameters": [
{
"name": "echo",
"in": "path",
"description": "The string to be echoed",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "user_key",
"in": "query",
"description": "Your API access key",
"required": true,
"x-data-threescale-name": "user_keys",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"text/xml": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ResponseModel"
}
}
}
},
"default": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"application/xml": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"text/xml": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"PingRequest": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
},
"required": ["message"]
},
"PingResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
}
},
"ResponseModel": {
"type": "object",
"required": ["method", "path", "args", "headers"],
"properties": {
"method": {
"type": "string"
},
"path": {
"type": "string"
},
"args": {
"type": "string"
},
"headers": {
"type": "object"
}
}
},
"ErrorModel": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
}
}
}
}
}
}

View File

@ -0,0 +1,209 @@
{
"openapi": "3.0.3",
"info": {
"title": "[Merged 3 API docs] Ping Service. Simple API. Title was not specified",
"description": "[Merged 3 API docs] A simple API that responds with the input message. List users API.",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com/v1",
"description": "Production Server"
},
{
"url": "https://api.staging.example.com/v1",
"description": "Staging Server"
}
],
"paths": {
"/ping": {
"post": {
"summary": "Ping message",
"description": "Returns the same message that was sent in the request body.",
"operationId": "pingMessage",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
},
"required": ["message"]
}
}
}
},
"responses": {
"200": {
"description": "The echoed message",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
}
}
}
}
},
"400": {
"description": "Invalid input, missing 'message' field"
}
}
}
},
"/users": {
"get": {
"summary": "Get all users",
"description": "Returns a list of users.",
"responses": {
"200": {
"description": "A list of users.",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
}
}
},
"/users/{userId}": {
"get": {
"summary": "Get a user by ID",
"description": "Returns a single user.",
"parameters": [
{
"name": "userId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "A single user.",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"404": {
"description": "User not found."
}
}
}
},
"/profile/{userId}": {
"get": {
"operationId": "getUserProfile",
"summary": "Get user profile by user ID",
"description": "Returns a user's profile details by their ID.",
"parameters": [
{
"in": "path",
"description": "ID of the user to fetch",
"name": "userId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/User1"
}
}
}
},
"404": {
"description": "User not found"
},
"500": {
"description": "Server error"
}
}
}
}
},
"components": {
"schemas": {
"PingRequest": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
},
"required": ["message"]
},
"PingResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Hello, world!"
}
}
},
"User": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
},
"User1": {
"properties": {
"email": {
"type": "string",
"description": "User email address",
"example": "test@example.com"
},
"id": {
"type": "string",
"description": "User ID",
"example": "1001"
},
"name": {
"type": "string",
"description": "User name",
"example": "John Doe"
}
}
}
}
}
}

View File

@ -0,0 +1,63 @@
{
"host": "api.example.com",
"basePath": "/v1",
"schemes": ["https"],
"swagger": "2.0",
"info": {
"title": "Title was not specified",
"version": "1.2"
},
"paths": {
"/profile/{userId}": {
"get": {
"operationId": "getUserProfile",
"summary": "Get user profile by user ID",
"description": "Returns a user's profile details by their ID.",
"parameters": [
{
"in": "path",
"description": "ID of the user to fetch",
"name": "userId",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "Successful response",
"schema": {
"$ref": "#/definitions/User"
}
},
"404": {
"description": "User not found"
},
"500": {
"description": "Server error"
}
}
}
}
},
"definitions": {
"User": {
"properties": {
"email": {
"type": "string",
"description": "User email address",
"example": "test@example.com"
},
"id": {
"type": "string",
"description": "User ID",
"example": "1001"
},
"name": {
"type": "string",
"description": "User name",
"example": "John Doe"
}
}
}
}
}

View File

@ -0,0 +1,53 @@
{
"proxy": {
"service_id": 2,
"endpoint": "https://api-3scale-apicast-production.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com:443",
"api_backend": "https://echo-api.3scale.net:443",
"credentials_location": "query",
"auth_app_key": "app_key",
"auth_app_id": "app_id",
"auth_user_key": "user_key",
"error_auth_failed": "Authentication failed",
"error_auth_missing": "Authentication parameters missing",
"error_status_auth_failed": 403,
"error_headers_auth_failed": "text/plain; charset=us-ascii",
"error_status_auth_missing": 403,
"error_headers_auth_missing": "text/plain; charset=us-ascii",
"error_no_match": "No Mapping Rule matched",
"error_status_no_match": 404,
"error_headers_no_match": "text/plain; charset=us-ascii",
"error_limits_exceeded": "Usage limit exceeded",
"error_status_limits_exceeded": 429,
"error_headers_limits_exceeded": "text/plain; charset=us-ascii",
"secret_token": "Shared_secret_sent_from_proxy_to_API_backend_60e69c4e8b2d7666",
"sandbox_endpoint": "https://api-3scale-apicast-staging.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com:443",
"api_test_path": "/",
"api_test_success": true,
"policies_config": [
{
"name": "apicast",
"version": "builtin",
"configuration": {},
"enabled": true
}
],
"created_at": "2024-09-17T09:57:53Z",
"updated_at": "2024-09-17T10:06:42Z",
"deployment_option": "hosted",
"lock_version": 1,
"links": [
{
"rel": "mapping_rules",
"href": "/admin/api/services/2/proxy/mapping_rules"
},
{
"rel": "self",
"href": "/admin/api/services/2/proxy"
},
{
"rel": "service",
"href": "/admin/api/services/2"
}
]
}
}

View File

@ -0,0 +1,49 @@
{
"services": [
{
"service": {
"id": 2,
"name": "API",
"description": "Nice API",
"state": "incomplete",
"system_name": "api",
"backend_version": "1",
"deployment_option": "hosted",
"support_email": "admin@3scale.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com",
"intentions_required": false,
"buyers_manage_apps": true,
"buyers_manage_keys": true,
"referrer_filters_required": false,
"custom_keys_enabled": true,
"buyer_key_regenerate_enabled": true,
"mandatory_app_key": true,
"buyer_can_select_plan": false,
"buyer_plan_change_permission": "request",
"created_at": "2024-09-17T09:57:53Z",
"updated_at": "2024-09-17T10:06:42Z",
"links": [
{
"rel": "metrics",
"href": "https://3scale-admin.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com/admin/api/services/2/metrics"
},
{
"rel": "self",
"href": "https://3scale-admin.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com/admin/api/services/2"
},
{
"rel": "service_plans",
"href": "https://3scale-admin.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com/admin/api/services/2/service_plans"
},
{
"rel": "application_plans",
"href": "https://3scale-admin.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com/admin/api/services/2/application_plans"
},
{
"rel": "features",
"href": "https://3scale-admin.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com/admin/api/services/2/features"
}
]
}
}
]
}

View File

@ -0,0 +1,356 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mockServices } from '@backstage/backend-test-utils';
import { ThreeScaleApiEntityProvider } from './ThreeScaleApiEntityProvider';
import { ConfigReader } from '@backstage/config';
import {
SchedulerService,
SchedulerServiceTaskRunner,
} from '@backstage/backend-plugin-api';
import { resolve } from 'path';
import fs from 'fs';
const requestJsonDataMock = jest.fn().mockResolvedValue([]);
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(requestJsonDataMock()),
headers: new Headers(),
text: () => Promise.resolve('mocked text data'),
} as Response),
);
const loggerMock = mockServices.logger.mock();
const schedulerTaskRunnerMock = {
run: jest.fn().mockImplementation(),
};
const shedulerServiceMock = {
triggerTask: jest.fn().mockImplementation(),
scheduleTask: jest.fn().mockImplementation(),
createScheduledTaskRunner: jest.fn().mockImplementation(),
getScheduledTasks: jest.fn().mockImplementation(),
};
const entityProviderConnection = {
applyMutation: jest.fn().mockImplementation(),
refresh: jest.fn().mockImplementation(),
};
describe('ThreeScaleApiEntityProvider', () => {
let conf: ConfigReader;
beforeEach(() => {
conf = new ConfigReader({
catalog: {
providers: {
threeScaleApiEntity: {
test: {
baseUrl: 'test',
accessToken: 'test',
},
},
},
},
});
});
function createApiEntityProvider(
schedule: SchedulerServiceTaskRunner,
scheduler: SchedulerService,
): ThreeScaleApiEntityProvider[] {
return ThreeScaleApiEntityProvider.fromConfig(
{ config: conf, logger: loggerMock },
{ schedule, scheduler },
);
}
it('should be defined', () => {
const threeScaleApiEntityProvider = createApiEntityProvider(
schedulerTaskRunnerMock,
shedulerServiceMock,
);
expect(threeScaleApiEntityProvider).toBeDefined();
expect(threeScaleApiEntityProvider).toBeInstanceOf(Array);
expect(threeScaleApiEntityProvider.length).toBe(1);
});
describe('run', () => {
let threeScaleApiEntityProvider: ThreeScaleApiEntityProvider;
beforeEach(async () => {
entityProviderConnection.applyMutation.mockClear();
threeScaleApiEntityProvider = createApiEntityProvider(
schedulerTaskRunnerMock,
shedulerServiceMock,
)[0];
await threeScaleApiEntityProvider.connect(entityProviderConnection);
});
it('should be created catalog entity with single open API 3.0 doc', async () => {
const services = readTestJSONFile('services');
requestJsonDataMock.mockResolvedValueOnce(services);
const openAPI3_0Spec = readTestJSONFile('input/open-api-3.0-doc');
const apiDoc = createAPIDoc(
'ping',
'ping',
'A simple API that responds with the input message.',
openAPI3_0Spec,
);
const apiDocs = { api_docs: [apiDoc] };
requestJsonDataMock.mockResolvedValueOnce(apiDocs);
const proxy = readTestJSONFile('proxy');
requestJsonDataMock.mockResolvedValueOnce(proxy);
await threeScaleApiEntityProvider.run();
const entities = [
createExpectedEntity(
'input/open-api-3.0-doc',
'A simple API that responds with the input message.',
),
];
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities,
});
});
it('should be created catalog entity with single api doc but swagger 2.0 should not be converted to API 3.0', async () => {
const services = readTestJSONFile('services');
requestJsonDataMock.mockResolvedValueOnce(services);
const swagger2_0Spec = readTestJSONFile('input/swagger-2.0-doc');
const apiDoc = createAPIDoc(
'list-users',
'List users API',
'List users API.',
swagger2_0Spec,
);
const apiDocs = { api_docs: [apiDoc] };
requestJsonDataMock.mockResolvedValueOnce(apiDocs);
const proxy = readTestJSONFile('proxy');
requestJsonDataMock.mockResolvedValueOnce(proxy);
await threeScaleApiEntityProvider.run();
const entities = [
createExpectedEntity('input/swagger-2.0-doc', 'List users API.'),
];
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities,
});
});
it('should be created catalog entity with single api doc but converted from swagger 1.2 to swagger 2.0', async () => {
const services = readTestJSONFile('services');
requestJsonDataMock.mockResolvedValueOnce(services);
const swagger1_2Spec = readTestJSONFile('input/swagger-1.2-doc');
const apiDoc = createAPIDoc(
'get-user-profile-by-id',
'Get User Profile By ID',
'User profile API.',
swagger1_2Spec,
);
const apiDocs = { api_docs: [apiDoc] };
requestJsonDataMock.mockResolvedValueOnce(apiDocs);
const proxy = readTestJSONFile('proxy');
requestJsonDataMock.mockResolvedValueOnce(proxy);
await threeScaleApiEntityProvider.run();
const entities = [
createExpectedEntity(
'output/swagger-1.2-converted-to-swagger-2.0',
'Nice API',
),
];
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities,
});
});
it('should be created catalog entity with merged 2 open API 3.0 docs', async () => {
const services = readTestJSONFile('services');
requestJsonDataMock.mockResolvedValueOnce(services);
const openAPI3_0Spec1 = readTestJSONFile('input/open-api-3.0-doc');
const apiDoc1 = createAPIDoc(
'ping',
'Ping',
'A simple API that responds with the input message.',
openAPI3_0Spec1,
);
const openAPI3_0Spec2 = readTestJSONFile('input/open-api-3.0-doc-2');
const apiDoc2 = createAPIDoc(
'echo',
'Echo',
'A sample echo API.',
openAPI3_0Spec2,
);
const apiDocs = { api_docs: [apiDoc1, apiDoc2] };
requestJsonDataMock.mockResolvedValueOnce(apiDocs);
const proxy = readTestJSONFile('proxy');
requestJsonDataMock.mockResolvedValueOnce(proxy);
await threeScaleApiEntityProvider.run();
const entities = [
createExpectedEntity(
'output/merged-2-open-api-3.0-docs',
'[Merged 2 API docs] A simple API that responds with the input message. A sample echo API.',
),
];
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities,
});
});
it('should be created catalog entity with merged 3 api docs in different formats', async () => {
const services = readTestJSONFile('services');
requestJsonDataMock.mockResolvedValueOnce(services);
const openAPI3_0Spec1 = readTestJSONFile('input/open-api-3.0-doc');
const apiDoc1 = createAPIDoc(
'ping',
'ping',
'A simple API that responds with the input message.',
openAPI3_0Spec1,
);
const swagger2_0Spec = readTestJSONFile('input/swagger-2.0-doc');
const apiDoc2 = createAPIDoc(
'list-users',
'List users API',
'List users API.',
swagger2_0Spec,
);
const swagger1_2Spec = readTestJSONFile('input/swagger-1.2-doc');
const apiDoc3 = createAPIDoc(
'get-user-profile-by-id',
'Get User Profile By ID',
'User profile API.',
swagger1_2Spec,
);
const apiDocs = { api_docs: [apiDoc1, apiDoc2, apiDoc3] };
requestJsonDataMock.mockResolvedValueOnce(apiDocs);
const proxy = readTestJSONFile('proxy');
requestJsonDataMock.mockResolvedValueOnce(proxy);
await threeScaleApiEntityProvider.run();
const entities = [
createExpectedEntity(
'output/merged-3-different-api-docs',
'[Merged 3 API docs] A simple API that responds with the input message. List users API.',
),
];
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities,
});
});
});
});
function readTestJSONFile(fileName: string): any {
const file = resolve(__dirname, `./../__fixtures__/data/${fileName}.json`);
const fileContent = fs.readFileSync(file, 'utf8');
return JSON.parse(fileContent);
}
function createExpectedEntity(
fileWithExpectedOpenAPISpec: string,
description: string,
): any {
return {
entity: {
kind: 'API',
apiVersion: 'backstage.io/v1alpha1',
metadata: {
annotations: {
'backstage.io/managed-by-location': 'url:test/apiconfig/services/2',
'backstage.io/managed-by-origin-location':
'url:test/apiconfig/services/2',
},
name: 'api',
description,
links: [
{
url: 'test/apiconfig/services/2',
title: '3scale Overview',
},
{
url: 'https://api-3scale-apicast-staging.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com:443',
title: 'Staging Apicast Endpoint',
},
{
url: 'https://api-3scale-apicast-production.apps.rosa.ceazq-ocd3j-vy9.8aja.p3.openshiftapps.com:443',
title: 'Production Apicast Endpoint',
},
],
},
spec: {
type: 'openapi',
lifecycle: 'test',
system: '3scale',
owner: '3scale',
definition: JSON.stringify(
readTestJSONFile(fileWithExpectedOpenAPISpec),
null,
2,
),
},
},
locationKey: 'ThreeScaleApiEntityProvider:test',
};
}
function createAPIDoc(
systemName: string,
name: string,
description: string,
apiDocBody: any,
) {
return {
api_doc: {
id: 1,
system_name: systemName,
name: name,
description: description,
published: true,
skip_swagger_validations: false,
body: JSON.stringify(apiDocBody),
service_id: 2,
created_at: '2024-09-17T10:09:04Z',
updated_at: '2024-09-17T10:09:04Z',
},
};
}

View File

@ -19,7 +19,6 @@ import type {
SchedulerService,
LoggerService,
} from '@backstage/backend-plugin-api';
import {
ANNOTATION_LOCATION,
ANNOTATION_ORIGIN_LOCATION,
@ -48,7 +47,14 @@ import type {
Services,
} from '../clients/types';
import { readThreeScaleApiEntityConfigs } from './config';
import type { ThreeScaleConfig } from './types';
import { isNonEmptyArray, NonEmptyArray, ThreeScaleConfig } from './types';
import {
isOpenAPI3_0,
isSwagger1_2,
isSwagger2_0,
OpenAPIMergerAndConverter,
} from './open-api-merger-converter';
import { Swagger } from 'atlassian-openapi';
export class ThreeScaleApiEntityProvider implements EntityProvider {
private static SERVICES_FETCH_SIZE: number = 500;
@ -57,6 +63,7 @@ export class ThreeScaleApiEntityProvider implements EntityProvider {
private readonly accessToken: string;
private readonly logger: LoggerService;
private readonly scheduleFn: () => Promise<void>;
private readonly openApiMerger: OpenAPIMergerAndConverter;
private connection?: EntityProviderConnection;
static fromConfig(
@ -119,6 +126,7 @@ export class ThreeScaleApiEntityProvider implements EntityProvider {
});
this.scheduleFn = this.createScheduleFn(taskRunner);
this.openApiMerger = new OpenAPIMergerAndConverter();
}
private createScheduleFn(
@ -186,24 +194,19 @@ export class ThreeScaleApiEntityProvider implements EntityProvider {
const service = element;
this.logger.debug(`Find service ${service.service.name}`);
// Trying to find the API Doc for the service and validate if api doc was assigned to an API.
const apiDoc = apiDocs.api_docs.find(obj => {
if (obj.api_doc.service_id !== undefined) {
return obj.api_doc.service_id === service.service.id;
}
return false;
});
const docs = apiDocs.api_docs.filter(
obj => obj.api_doc.service_id === service.service.id,
);
const proxy = await getProxyConfig(
this.baseUrl,
this.accessToken,
service.service.id,
);
if (apiDoc !== undefined) {
this.logger.info(JSON.stringify(apiDoc));
const apiEntity: ApiEntity = this.buildApiEntityFromService(
if (isNonEmptyArray(docs)) {
this.logger.info(JSON.stringify(docs));
const apiEntity: ApiEntity = await this.buildApiEntityFromService(
service,
apiDoc,
docs,
proxy,
);
entities.push(apiEntity);
@ -231,14 +234,55 @@ export class ThreeScaleApiEntityProvider implements EntityProvider {
});
}
private buildApiEntityFromService(
private async buildApiEntityFromService(
service: ServiceElement,
apiDoc: APIDocElement,
apiDocs: NonEmptyArray<APIDocElement>,
proxy: Proxy,
): ApiEntity {
): Promise<ApiEntity> {
const location = `url:${this.baseUrl}/apiconfig/services/${service.service.id}`;
const serviceDescription = service.service.description || '';
let entityDescription: string | undefined;
const spec = JSON.parse(apiDoc.api_doc.body);
const docs = apiDocs.map(doc => JSON.parse(doc.api_doc.body));
let swaggerDocJSON;
if (docs.length > 1) {
// convert all docs to openapi 3.0 and merge them
let mergedDescription = `[Merged ${docs.length} API docs]`;
let mergedTitle = mergedDescription;
const convertedDocs: Swagger.SwaggerV3[] = [];
for (const doc of docs) {
const convertedDoc = await this.openApiMerger.convertAPIDocToOpenAPI3(
doc,
);
convertedDocs.push(convertedDoc);
mergedDescription = getDocInfo(convertedDoc)?.description
? `${mergedDescription} ${getDocInfo(convertedDoc)?.description}`
: mergedDescription;
mergedTitle = getDocInfo(convertedDoc)?.title
? `${mergedTitle} ${getDocInfo(convertedDoc)?.title}`
: mergedTitle;
}
if (isNonEmptyArray(convertedDocs)) {
swaggerDocJSON = await this.openApiMerger.mergeOpenAPI3Docs(
convertedDocs,
);
swaggerDocJSON.info.description = mergedDescription;
swaggerDocJSON.info.title = mergedTitle;
entityDescription = mergedDescription;
}
}
if (docs.length === 1) {
swaggerDocJSON = docs[0];
const spec = JSON.parse(apiDocs[0].api_doc.body);
if (isSwagger1_2(spec)) {
// Backstage UI can render only openapi 3.0 or swagger 2.0. That's why we need to convert swagger 1.2 to swagger 2.0.
swaggerDocJSON = await this.openApiMerger.convertSwagger1_2To2_0(spec);
}
entityDescription = getDocInfo(spec)?.description;
}
return {
kind: 'API',
@ -250,8 +294,7 @@ export class ThreeScaleApiEntityProvider implements EntityProvider {
},
// TODO: add tenant name
name: `${service.service.system_name}`,
description:
spec.info.description || `Version: ${service.service.description}`,
description: entityDescription || serviceDescription,
// TODO: add labels
// labels: this.getApiEntityLabels(service),
links: [
@ -274,8 +317,19 @@ export class ThreeScaleApiEntityProvider implements EntityProvider {
lifecycle: this.env,
system: '3scale',
owner: '3scale',
definition: apiDoc.api_doc.body,
definition: JSON.stringify(swaggerDocJSON, null, 2),
},
};
}
}
function getDocInfo(
spec: any,
): { description: string; title: string } | undefined {
if (isSwagger2_0(spec) || isOpenAPI3_0(spec)) {
return spec.info;
}
// swagger 1.2 spec doc defined by single file doesn't have description field
return undefined;
}

View File

@ -0,0 +1,97 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { merge, isErrorResult, MergeInput } from 'openapi-merge';
import { Swagger } from 'atlassian-openapi';
import Swagger2OpenAPI from 'swagger2openapi';
// @ts-ignore
import SwaggerConverter from 'swagger-converter';
import { NonEmptyArray } from './types';
export function isSwagger1_2(apiDoc: any): boolean {
return apiDoc.swaggerVersion && apiDoc.swaggerVersion === '1.2';
}
export function isSwagger2_0(apiDoc: any): boolean {
return apiDoc.swagger && apiDoc.swagger === '2.0';
}
export function isOpenAPI3_0(apiDoc: any): boolean {
return apiDoc.openapi;
}
export class OpenAPIMergerAndConverter {
async mergeOpenAPI3Docs(
docs: NonEmptyArray<Swagger.SwaggerV3>,
): Promise<Swagger.SwaggerV3> {
const mergeInput: MergeInput = docs.map(doc => {
return { oas: doc };
});
const result = await merge(mergeInput);
if (isErrorResult(result)) {
throw new Error(result.message);
}
return result.output;
}
// Convert api doc to format openAPI 3. Do nothing with doc if it has format openAPI 3.0.
// 3scale supports API docs in formats:
// - swagger 1.2
// - swagger 2.0
// - openAPI 3.0
async convertAPIDocToOpenAPI3(apiDoc: any): Promise<Swagger.SwaggerV3> {
if (isOpenAPI3_0(apiDoc)) {
return apiDoc;
}
if (isSwagger1_2(apiDoc)) {
// Unfortunately there is no library in the JavaScript world, which can convert both swagger 1.2 and 2.0 to openAPI 3.0.
// That's why, for swagger 1.2 we are using convertation to swagger 2.0. And then swagger 2.0 will be converted to openAPI 3.0.
const swagger2_0Doc = await this.convertSwagger1_2To2_0(apiDoc);
return await this.convertSwagger2_0ToOpenAPI3_0(swagger2_0Doc);
}
if (isSwagger2_0(apiDoc)) {
return await this.convertSwagger2_0ToOpenAPI3_0(apiDoc);
}
throw new Error(
`Unsupported API document. Plugin supports Swagger 1.2, 2.0, 3.0(Open API 3.0)`,
);
}
async convertSwagger1_2To2_0(swaggerDoc: any): Promise<any> {
try {
const result = SwaggerConverter.convert(swaggerDoc, {});
return result;
} catch (error) {
console.error('Error converting Swagger 1.2 to Swagger 2.0:', error);
throw error;
}
}
private async convertSwagger2_0ToOpenAPI3_0(swaggerDoc: any): Promise<any> {
try {
const result = await Swagger2OpenAPI.convertObj(swaggerDoc, {
patch: true, // patch: true helps to fix minor issues
warnOnly: true, // Do not throw on non-patchable errors
});
return result.openapi;
} catch (error) {
console.error('Error converting Swagger 2.0 to OpenAPI 3.0:', error);
throw error;
}
}
}

View File

@ -25,3 +25,9 @@ export type ThreeScaleConfig = {
addLabels?: boolean;
schedule?: SchedulerServiceTaskScheduleDefinition;
};
export type NonEmptyArray<T> = [T, ...T[]];
export function isNonEmptyArray<T>(arr: T[]): arr is NonEmptyArray<T> {
return arr.length > 0;
}

File diff suppressed because it is too large Load Diff