Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: serve swagger UI in LocalStack #8

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions localstack-core/localstack/logging/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"urllib3": logging.WARNING,
"werkzeug": logging.WARNING,
"rolo": logging.WARNING,
"parse": logging.WARNING,
"localstack.aws.accounts": logging.INFO,
"localstack.aws.protocol.serializer": logging.INFO,
"localstack.aws.serving.wsgi": logging.WARNING,
Expand Down
29 changes: 29 additions & 0 deletions localstack-core/localstack/plugins.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import importlib
import logging

import werkzeug
import yaml
from rolo.routing import RuleAdapter

from localstack import config
from localstack.aws.handlers.validation import OASPlugin
from localstack.http import Response
from localstack.runtime import hooks
from localstack.services.edge import ROUTER
from localstack.services.internal import get_internal_apis
from localstack.utils.files import rm_rf
from localstack.utils.openapi import get_localstack_openapi_spec
from localstack.utils.ssl import get_cert_pem_file_path
from localstack.utils.swagger import SwaggerUIApi

LOG = logging.getLogger(__name__)

Expand All @@ -24,5 +34,24 @@ def delete_cached_certificate():
rm_rf(target_file)


@hooks.on_infra_start()
def register_swagger_endpoints():
get_internal_apis().add(SwaggerUIApi())

def _serve_static_file(_request, path: str):
module = importlib.import_module("localstack.static")
return Response.for_resource(module, path)

def _serve_openapi_spec(_request):
spec = get_localstack_openapi_spec()
response_body = yaml.dump(spec)
return werkzeug.Response(
response_body, content_type="application/yaml", direct_passthrough=True
)

ROUTER.add(RuleAdapter("/openapi.yaml", _serve_openapi_spec))
ROUTER.add(RuleAdapter("/static/<path:path>", _serve_static_file))


class CoreOASPlugin(OASPlugin):
name = "localstack"
Empty file.
Binary file added localstack-core/localstack/static/favicon-16x16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added localstack-core/localstack/static/favicon-32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions localstack-core/localstack/static/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}

*,
*:before,
*:after {
box-sizing: inherit;
}

body {
margin: 0;
background: #fafafa;
}
79 changes: 79 additions & 0 deletions localstack-core/localstack/static/oauth2-redirect.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Swagger UI: OAuth2 Redirect</title>
</head>
<body>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;

if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1).replace('?', '&');
} else {
qp = location.search.substring(1);
}

arr = qp.split("&");
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value);
}
) : {};

isValid = qp.state === sentState;

if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
});
}

if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg;
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}

oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}

if (document.readyState !== 'loading') {
run();
} else {
document.addEventListener('DOMContentLoaded', function () {
run();
});
}
</script>
</body>
</html>
20 changes: 20 additions & 0 deletions localstack-core/localstack/static/swagger-initializer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
window.onload = function() {
//<editor-fold desc="Changeable Configuration Block">

// the following lines will be replaced by docker/configurator, when it runs in a docker-container
window.ui = SwaggerUIBundle({
url: "{{ swagger_url }}",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});

//</editor-fold>
};
2 changes: 2 additions & 0 deletions localstack-core/localstack/static/swagger-ui-bundle.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions localstack-core/localstack/static/swagger-ui-bundle.js.map

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions localstack-core/localstack/static/swagger-ui.css

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions localstack-core/localstack/static/swagger-ui.css.map

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions localstack-core/localstack/static/swagger-ui.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions localstack-core/localstack/static/swagger-ui.js.map

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions localstack-core/localstack/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="/static/swagger-ui.css" />
<link rel="stylesheet" type="text/css" href="/static/index.css" />
<link rel="icon" type="image/png" href="/static/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/static/favicon-16x16.png" sizes="16x16" />
</head>

<body>
<div id="swagger-ui"></div>
<script src="/static/swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="/static/swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script src="/_localstack/swagger-initializer.js" charset="UTF-8"> </script>
</body>
</html>
83 changes: 83 additions & 0 deletions localstack-core/localstack/utils/openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import copy
import logging
import textwrap
from typing import Any

import yaml
from plux import PluginManager

from localstack import version

LOG = logging.getLogger(__name__)

spec_top_info = textwrap.dedent("""
openapi: 3.1.0
info:
contact:
email: [email protected]
name: LocalStack Support
url: https://www.localstack.cloud/contact
summary: The LocalStack REST API exposes functionality related to diagnostics, health
checks, plugins, initialisation hooks, service introspection, and more.
termsOfService: https://www.localstack.cloud/legal/tos
title: LocalStack REST API
version: 1.0
externalDocs:
description: LocalStack Documentation
url: https://docs.localstack.cloud
servers:
- url: http://{host}:{port}
variables:
port:
default: '4566'
host:
default: 'localhost.localstack.cloud'
""")


def _merge_openapi_specs(specs: list[dict[str, Any]]) -> dict[str, Any]:
"""
Merge a list of OpenAPI specs into a single specification.
:param specs: a list of OpenAPI specs loaded in a dictionary
:return: the dictionary of a merged spec.
"""
Comment on lines +38 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider adding type hints for the return value in the function signature

merged_spec = {}
for idx, spec in enumerate(specs):
if idx == 0:
merged_spec = copy.deepcopy(spec)
else:
# Merge paths
if "paths" in spec:
merged_spec.setdefault("paths", {}).update(spec.get("paths", {}))

# Merge components
if "components" in spec:
if "components" not in merged_spec:
merged_spec["components"] = {}
for component_type, component_value in spec["components"].items():
if component_type not in merged_spec["components"]:
merged_spec["components"][component_type] = component_value
else:
merged_spec["components"][component_type].update(component_value)

# Update the initial part of the spec, i.e., info and correct LocalStack version
top_content = yaml.safe_load(spec_top_info)
# Set the correct version
top_content["info"]["version"] = version.version
merged_spec.update(top_content)
return merged_spec


def get_localstack_openapi_spec() -> dict[str, Any]:
"""
Collects all the declared OpenAPI specs in LocalStack.
Specs are declared by implementing a OASPlugin.
:return: the entire LocalStack OpenAPI spec in a Python dictionary.
"""
specs = PluginManager("localstack.openapi.spec").load_all()
try:
return _merge_openapi_specs([spec.spec for spec in specs])
except Exception as e:
LOG.debug("An error occurred while trying to merge the collected OpenAPI specs %s", e)
# In case of an error while merging the spec, we return the first collected one.
return specs[0].spec
Comment on lines +82 to +83
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Returning the first spec on error might lead to unexpected behavior. Consider raising a custom exception instead

31 changes: 31 additions & 0 deletions localstack-core/localstack/utils/swagger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os

from jinja2 import Environment, FileSystemLoader
from rolo import route

from localstack.config import external_service_url
from localstack.http import Response


class SwaggerUIApi:
init_path: str

def __init__(self) -> None:
self.init_path = f"{external_service_url()}/openapi.yaml"

@route("/_localstack/swagger", methods=["GET"])
def server_swagger_ui(self, _request):
oas_path = os.path.join(os.path.dirname(__file__), "..", "templates")
env = Environment(loader=FileSystemLoader(oas_path))
template = env.get_template("index.html")
rendered_template = template.render()
return Response(rendered_template, content_type="text/html")
Comment on lines +17 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider caching the rendered template to improve performance for subsequent requests


@route("/_localstack/swagger-initializer.js", methods=["GET"])
def serve_swagger_initializer(self, _request):
oas_path = os.path.join(os.path.dirname(__file__), "..", "static")
env = Environment(loader=FileSystemLoader(oas_path))
template = env.get_template("swagger-initializer.js")

rendered_template = template.render(swagger_url=self.init_path)
return Response(rendered_template, content_type="application/javascript")
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ exclude = ["tests*"]
"services/**/*.html",
"services/**/resource_providers/*.schema.json",
"utils/kinesis/java/cloud/localstack/*.*",
"openapi.yaml"
"openapi.yaml",
"static/*.*",
"templates/index.html"
]

[tool.ruff]
Expand Down