Allow the specification to be specified as a URL. (#1871)

Changes proposed in this pull request:

- Allow a specification to be specified as a URL that is downloaded when
the App runs. In combination with the existing mock features, this makes
it a single command to run a mock server for any published API.

---------

Co-authored-by: Robbe Sneyders <robbe.sneyders@gmail.com>
This commit is contained in:
Mark Perryman
2024-02-18 20:56:31 +00:00
committed by GitHub
parent 211bdb03f6
commit 994f53fb04
6 changed files with 69 additions and 11 deletions

View File

@@ -143,8 +143,8 @@ class AbstractApp:
Register an API represented by a single OpenAPI specification on this application. Register an API represented by a single OpenAPI specification on this application.
Multiple APIs can be registered on a single application. Multiple APIs can be registered on a single application.
:param specification: OpenAPI specification. Can be provided either as dict, or as path :param specification: OpenAPI specification. Can be provided either as dict, a path
to file. to file, or a URL.
:param base_path: Base path to host the API. This overrides the basePath / servers in the :param base_path: Base path to host the API. This overrides the basePath / servers in the
specification. specification.
:param name: Name to register the API with. If no name is passed, the base_path is used :param name: Name to register the API with. If no name is passed, the base_path is used

View File

@@ -117,10 +117,13 @@ def create_app(args: t.Optional[argparse.Namespace] = None) -> AbstractApp:
logging.basicConfig(level=logging_level) logging.basicConfig(level=logging_level)
spec_file_full_path = os.path.abspath(args.spec_file) if args.spec_file.startswith("http") or args.spec_file.startswith("https"):
py_module_path = args.base_module_path or os.path.dirname(spec_file_full_path) spec_file_full_path = args.spec_file
sys.path.insert(1, os.path.abspath(py_module_path)) else:
logger.debug(f"Added {py_module_path} to system path.") spec_file_full_path = os.path.abspath(args.spec_file)
py_module_path = args.base_module_path or os.path.dirname(spec_file_full_path)
sys.path.insert(1, os.path.abspath(py_module_path))
logger.debug(f"Added {py_module_path} to system path.")
resolver_error = None resolver_error = None
if args.stub: if args.stub:

View File

@@ -371,8 +371,8 @@ class ConnexionMiddleware:
Register een API represented by a single OpenAPI specification on this middleware. Register een API represented by a single OpenAPI specification on this middleware.
Multiple APIs can be registered on a single middleware. Multiple APIs can be registered on a single middleware.
:param specification: OpenAPI specification. Can be provided either as dict, or as path :param specification: OpenAPI specification. Can be provided either as dict, a path
to file. to file, or a URL.
:param base_path: Base path to host the API. This overrides the basePath / servers in the :param base_path: Base path to host the API. This overrides the basePath / servers in the
specification. specification.
:param name: Name to register the API with. If no name is passed, the base_path is used :param name: Name to register the API with. If no name is passed, the base_path is used
@@ -408,7 +408,11 @@ class ConnexionMiddleware:
if self.middleware_stack is not None: if self.middleware_stack is not None:
raise RuntimeError("Cannot add api after an application has started") raise RuntimeError("Cannot add api after an application has started")
if isinstance(specification, (pathlib.Path, str)): if isinstance(specification, str) and (
specification.startswith("http://") or specification.startswith("https://")
):
pass
elif isinstance(specification, (pathlib.Path, str)):
specification = t.cast(pathlib.Path, self.specification_dir / specification) specification = t.cast(pathlib.Path, self.specification_dir / specification)
# Add specification as file to watch for reloading # Add specification as file to watch for reloading

View File

@@ -19,7 +19,7 @@ from jsonschema import Draft4Validator
from jsonschema.validators import extend as extend_validator from jsonschema.validators import extend as extend_validator
from .exceptions import InvalidSpecification from .exceptions import InvalidSpecification
from .json_schema import NullableTypeValidator, resolve_refs from .json_schema import NullableTypeValidator, URLHandler, resolve_refs
from .operations import AbstractOperation, OpenAPIOperation, Swagger2Operation from .operations import AbstractOperation, OpenAPIOperation, Swagger2Operation
from .utils import deep_get from .utils import deep_get
@@ -158,6 +158,14 @@ class Specification(Mapping):
spec = cls._load_spec_from_file(arguments, specification_path) spec = cls._load_spec_from_file(arguments, specification_path)
return cls.from_dict(spec, base_uri=base_uri) return cls.from_dict(spec, base_uri=base_uri)
@classmethod
def from_url(cls, spec, *, base_uri=""):
"""
Takes in a path to a YAML file, and returns a Specification
"""
spec = URLHandler()(spec)
return cls.from_dict(spec, base_uri=base_uri)
@staticmethod @staticmethod
def _get_spec_version(spec): def _get_spec_version(spec):
try: try:
@@ -200,6 +208,10 @@ class Specification(Mapping):
@classmethod @classmethod
def load(cls, spec, *, arguments=None): def load(cls, spec, *, arguments=None):
if isinstance(spec, str) and (
spec.startswith("http://") or spec.startswith("https://")
):
return cls.from_url(spec)
if not isinstance(spec, dict): if not isinstance(spec, dict):
base_uri = f"{pathlib.Path(spec).parent}{os.sep}" base_uri = f"{pathlib.Path(spec).parent}{os.sep}"
return cls.from_file(spec, arguments=arguments, base_uri=base_uri) return cls.from_file(spec, arguments=arguments, base_uri=base_uri)

View File

@@ -30,7 +30,8 @@ The basic usage of this command is:
Where: Where:
- SPEC_FILE: Your OpenAPI specification file in YAML format. - SPEC_FILE: Your OpenAPI specification file in YAML format. Can also be given
as a URL, which will be automatically downloaded.
- BASE_MODULE_PATH (optional): filesystem path where the API endpoints - BASE_MODULE_PATH (optional): filesystem path where the API endpoints
handlers are going to be imported from. In short, where your Python handlers are going to be imported from. In short, where your Python
code is saved. code is saved.
@@ -52,3 +53,5 @@ Your API specification file is not required to have any ``operationId``.
.. code-block:: bash .. code-block:: bash
$ connexion run your_api.yaml --mock=all $ connexion run your_api.yaml --mock=all
$ connexion run https://raw.githubusercontent.com/spec-first/connexion/main/examples/helloworld_async/spec/openapi.yaml --mock=all

View File

@@ -51,6 +51,42 @@ def test_api_base_path_slash():
assert api.blueprint.url_prefix == "" assert api.blueprint.url_prefix == ""
def test_remote_api():
api = FlaskApi(
Specification.load(
"https://raw.githubusercontent.com/spec-first/connexion/165a915/tests/fixtures/simple/swagger.yaml"
),
base_path="/api/v1.0",
)
assert api.blueprint.name == "/api/v1_0"
assert api.blueprint.url_prefix == "/api/v1.0"
api2 = FlaskApi(
Specification.load(
"https://raw.githubusercontent.com/spec-first/connexion/165a915/tests/fixtures/simple/swagger.yaml"
)
)
assert api2.blueprint.name == "/v1_0"
assert api2.blueprint.url_prefix == "/v1.0"
api3 = FlaskApi(
Specification.load(
"https://raw.githubusercontent.com/spec-first/connexion/165a915/tests/fixtures/simple/openapi.yaml"
),
base_path="/api/v1.0",
)
assert api3.blueprint.name == "/api/v1_0"
assert api3.blueprint.url_prefix == "/api/v1.0"
api4 = FlaskApi(
Specification.load(
"https://raw.githubusercontent.com/spec-first/connexion/165a915/tests/fixtures/simple/openapi.yaml"
)
)
assert api4.blueprint.name == "/v1_0"
assert api4.blueprint.url_prefix == "/v1.0"
def test_template(): def test_template():
api1 = FlaskApi( api1 = FlaskApi(
Specification.load( Specification.load(