diff --git a/README.md b/README.md index 0d6df476..1d1a52bd 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,17 @@ and production-ready services, offering automatic deployment for ML models. Some remarkable characteristics: -* Generic classes for API resources with the convenience of standard CRUD methods over SQLAlchemy tables. -* A schema system (based on Marshmallow or Typesystem) which allows the declaration of inputs and outputs of endpoints +- Generic classes for API resources with the convenience of standard CRUD methods over SQLAlchemy tables. +- A schema system (based on Marshmallow or Typesystem) which allows the declaration of inputs and outputs of endpoints very easily, with the convenience of reliable and automatic data-type validation. -* Dependency injection to make ease the process of managing parameters needed in endpoints via the use of `Component`s. +- Dependency injection to make ease the process of managing parameters needed in endpoints via the use of `Component`s. Flama ASGI objects like `Request`, `Response`, `Session` and so on are defined as `Component`s ready to be injected in your endpoints. -* `Component`s as the base of the plugin ecosystem, allowing you to create custom or use those already defined in your +- `Component`s as the base of the plugin ecosystem, allowing you to create custom or use those already defined in your endpoints, injected as parameters. -* Auto generated API schema using OpenAPI standard. -* Auto generated `docs`, and provides a Swagger UI and ReDoc endpoints. -* Automatic handling of pagination, with several methods at your disposal such as `limit-offset` and `page numbering`, +- Auto generated API schema using OpenAPI standard. +- Auto generated `docs`, and provides a Swagger UI and ReDoc endpoints. +- Automatic handling of pagination, with several methods at your disposal such as `limit-offset` and `page numbering`, to name a few. ## Installation @@ -52,7 +52,7 @@ Flama is fully compatible with all [supported versions](https://devguide.python. you to use the latest version available. For a detailed explanation on how to install flama -visit: [https://flama.dev/docs/getting-started/installation](https://flama.dev/docs/getting-started/installation). +visit: [https://flama.dev/docs/getting-started/installation](https://flama.dev/docs/getting-started/installation). ## Getting Started @@ -68,11 +68,7 @@ Visit [https://flama.dev/docs/](https://flama.dev/docs/) to view the full docume ```python from flama import Flama -app = Flama( - title="Hello-🔥", - version="1.0", - description="My first API", -) +app = Flama() @app.route("/") @@ -101,8 +97,8 @@ flama run examples.hello_flama:app ## Authors -* José Antonio Perdiguero López ([@perdy](https://github.com/perdy/)) -* Miguel Durán-Olivencia ([@migduroli](https://github.com/migduroli/)) +- José Antonio Perdiguero López ([@perdy](https://github.com/perdy/)) +- Miguel Durán-Olivencia ([@migduroli](https://github.com/migduroli/)) ## Contributing diff --git a/examples/add_model_component.py b/examples/add_model_component.py index 9b810422..b29b8e48 100644 --- a/examples/add_model_component.py +++ b/examples/add_model_component.py @@ -90,9 +90,13 @@ def metadata(self): app = Flama( - title="Flama ML", - version="0.1.0", - description="Machine learning API using Flama 🔥", + openapi={ + "info": { + "title": "Flama ML", + "version": "0.1.0", + "description": "Machine learning API using Flama 🔥", + } + }, docs="/docs/", components=[component], ) diff --git a/examples/add_model_resource.py b/examples/add_model_resource.py index 841fcc75..ed5d69c5 100644 --- a/examples/add_model_resource.py +++ b/examples/add_model_resource.py @@ -9,9 +9,13 @@ from flama.resources import resource_method app = Flama( - title="Flama ML", - version="0.1.0", - description="Machine learning API using Flama 🔥", + openapi={ + "info": { + "title": "Flama ML", + "version": "0.1.0", + "description": "Machine learning API using Flama 🔥", + } + }, docs="/docs/", ) diff --git a/examples/add_models.py b/examples/add_models.py index 0531ece8..c84f6dcd 100644 --- a/examples/add_models.py +++ b/examples/add_models.py @@ -60,9 +60,13 @@ def user(username: str): app = Flama( - title="Flama ML", - version="0.1.0", - description="Machine learning API using Flama 🔥", + openapi={ + "info": { + "title": "Flama ML", + "version": "0.1.0", + "description": "Machine learning API using Flama 🔥", + } + }, routes=[ routing.Route("/", home), routing.Route("/user/me", user_me), diff --git a/examples/data_schema.py b/examples/data_schema.py index bc55d9eb..f0d31eb7 100755 --- a/examples/data_schema.py +++ b/examples/data_schema.py @@ -6,9 +6,13 @@ from flama import Flama, schemas app = Flama( - title="Puppy Register", # API title - version="0.1", # API version - description="A register of puppies", # API description + openapi={ + "info": { + "title": "Puppy Register", # API title + "version": "0.1", # API version + "description": "A register of puppies", # API description + } + }, schema="/schema/", # Path to expose OpenAPI schema docs="/docs/", # Path to expose Docs application ) diff --git a/examples/error.py b/examples/error.py index 9000e33b..b6ee7cd8 100755 --- a/examples/error.py +++ b/examples/error.py @@ -2,9 +2,16 @@ from flama import Flama, routing app = Flama( - title="Hello-🔥", - version="1.0", - description="My first API", + openapi={ + "info": { + "title": "Hello-🔥", + "version": "1.0", + "description": "My first API", + }, + "tags": [ + {"name": "Salute", "description": "This is the salute description"}, + ], + }, debug=True, ) diff --git a/examples/hello_flama.py b/examples/hello_flama.py index 4c987316..101e6903 100755 --- a/examples/hello_flama.py +++ b/examples/hello_flama.py @@ -1,6 +1,17 @@ import flama -app = flama.Flama(title="Hello-🔥", version="1.0", description="My first API") +app = flama.Flama( + openapi={ + "info": { + "title": "Hello-🔥", + "version": "1.0", + "description": "My first API", + }, + "tags": [ + {"name": "Salute", "description": "This is the salute description"}, + ], + } +) @app.route("/") diff --git a/examples/pagination.py b/examples/pagination.py index 51dea956..8e67e75c 100755 --- a/examples/pagination.py +++ b/examples/pagination.py @@ -24,9 +24,13 @@ def minimum_age_validation(cls, v): app = Flama( - title="Puppy Register", # API title - version="0.1", # API version - description="A register of puppies", # API description + openapi={ + "info": { + "title": "Puppy Register", # API title + "version": "0.1", # API version + "description": "A register of puppies", # API description + } + }, ) diff --git a/examples/resource.py b/examples/resource.py index b49fbe3a..0ab2d9d6 100755 --- a/examples/resource.py +++ b/examples/resource.py @@ -33,9 +33,13 @@ class PuppyResource(CRUDResource): app = Flama( - title="Puppy Register", # API title - version="0.1.0", # API version - description="A register of puppies", # API description + openapi={ + "info": { + "title": "Puppy Register", # API title + "version": "0.1.0", # API version + "description": "A register of puppies", # API description + } + }, modules=[SQLAlchemyModule(database=DATABASE_URL)], ) diff --git a/flama/applications.py b/flama/applications.py index 207f1223..3e55ad48 100644 --- a/flama/applications.py +++ b/flama/applications.py @@ -30,6 +30,7 @@ class Flama: def __init__( self, + *, routes: t.Optional[t.Sequence["routing.BaseRoute"]] = None, components: t.Optional[t.Union[t.Sequence[injection.Component], set[injection.Component]]] = None, modules: t.Optional[t.Union[t.Sequence["Module"], set["Module"]]] = None, @@ -37,9 +38,14 @@ def __init__( debug: bool = False, events: t.Optional[t.Union[dict[str, list[t.Callable[..., t.Coroutine[t.Any, t.Any, None]]]], Events]] = None, lifespan: t.Optional[t.Callable[[t.Optional["Flama"]], t.AsyncContextManager]] = None, - title: str = "Flama", - version: str = "0.1.0", - description: str = "Firing up with the flame", + openapi: types.OpenAPISpec = { + "info": { + "title": "Flama", + "version": "0.1.0", + "summary": "Flama application", + "description": "Firing up with the flame", + }, + }, schema: t.Optional[str] = "/schema/", docs: t.Optional[str] = "/docs/", schema_library: t.Optional[str] = None, @@ -92,7 +98,7 @@ def __init__( # Initialise modules default_modules = [ ResourcesModule(worker=worker), - SchemaModule(title, version, description, schema=schema, docs=docs), + SchemaModule(openapi, schema=schema, docs=docs), ModelsModule(), ] self.modules = Modules(app=self, modules={*default_modules, *(modules or [])}) diff --git a/flama/cli/templates/app.py.j2 b/flama/cli/templates/app.py.j2 index b5dac6a0..f3b0349d 100644 --- a/flama/cli/templates/app.py.j2 +++ b/flama/cli/templates/app.py.j2 @@ -2,9 +2,13 @@ from flama import Flama app = Flama( debug={{ debug }}, - title="{{ title }}", - version="{{ version }}", - description="{{ description }}", + openapi={ + "info": { + "title": "{{ title }}", + "version": "{{ version }}", + "description": "{{ description }}", + } + }, schema="{{ schema }}", docs="{{ docs }}" ) diff --git a/flama/compat.py b/flama/compat.py index 7aa2a13d..922bca99 100644 --- a/flama/compat.py +++ b/flama/compat.py @@ -1,6 +1,6 @@ import sys -__all__ = ["Concatenate", "ParamSpec", "TypeGuard", "UnionType", "StrEnum", "tomllib"] +__all__ = ["Concatenate", "ParamSpec", "TypeGuard", "UnionType", "NotRequired", "StrEnum", "tomllib"] # PORT: Remove when stop supporting 3.9 # Concatenate was added in Python 3.10 @@ -37,6 +37,14 @@ else: from typing import Union as UnionType +# PORT: Remove when stop supporting 3.10 +# NotRequired was added in Python 3.11 +# https://docs.python.org/3/library/enum.html#enum.StrEnum +if sys.version_info >= (3, 11): + from typing import NotRequired +else: + from typing_extensions import NotRequired + # PORT: Remove when stop supporting 3.10 # StrEnum was added in Python 3.11 diff --git a/flama/schemas/generator.py b/flama/schemas/generator.py index f8d45467..a15a5a1a 100644 --- a/flama/schemas/generator.py +++ b/flama/schemas/generator.py @@ -242,35 +242,8 @@ def get_openapi_ref( class SchemaGenerator: - def __init__( - self, - title: str, - version: str, - description: t.Optional[str] = None, - terms_of_service: t.Optional[str] = None, - contact_name: t.Optional[str] = None, - contact_url: t.Optional[str] = None, - contact_email: t.Optional[str] = None, - license_name: t.Optional[str] = None, - license_url: t.Optional[str] = None, - schemas: t.Optional[dict] = None, - ): - contact = ( - openapi.Contact(name=contact_name, url=contact_url, email=contact_email) - if contact_name or contact_url or contact_email - else None - ) - - license = openapi.License(name=license_name, url=license_url) if license_name else None - - self.spec = openapi.OpenAPISpec( - title=title, - version=version, - description=description, - terms_of_service=terms_of_service, - contact=contact, - license=license, - ) + def __init__(self, spec: types.OpenAPISpec, schemas: t.Optional[dict[str, schemas.Schema]] = None): + self.spec = openapi.OpenAPISpec.from_spec(spec) # Builtin definitions self.schemas = SchemaRegistry(schemas=schemas) diff --git a/flama/schemas/modules.py b/flama/schemas/modules.py index e803f943..da29e2fa 100644 --- a/flama/schemas/modules.py +++ b/flama/schemas/modules.py @@ -2,7 +2,7 @@ from pathlib import Path from types import ModuleType -from flama import http, pagination, schemas +from flama import http, pagination, schemas, types from flama.modules import Module from flama.schemas.generator import SchemaGenerator @@ -14,22 +14,13 @@ class SchemaModule(Module): name = "schema" - def __init__( - self, - title: str, - version: str, - description: str, - schema: t.Optional[str] = None, - docs: t.Optional[str] = None, - ): + def __init__(self, openapi: types.OpenAPISpec, *, schema: t.Optional[str] = None, docs: t.Optional[str] = None): super().__init__() # Schema definitions self.schemas: dict[str, t.Any] = {} # Schema - self.title = title - self.version = version - self.description = description + self.openapi = openapi self.schema_path = schema self.docs_path = docs @@ -48,9 +39,7 @@ def schema_generator(self) -> SchemaGenerator: :return: API Schema Generator. """ self.schemas.update({**schemas.schemas.SCHEMAS, **pagination.paginator.schemas}) - return SchemaGenerator( - title=self.title, version=self.version, description=self.description, schemas=self.schemas - ) + return SchemaGenerator(spec=self.openapi, schemas=self.schemas) @property def schema(self) -> dict[str, t.Any]: @@ -81,13 +70,10 @@ def add_routes(self) -> None: if self.schema_path: self.app.add_route(self.schema_path, self.schema_view, methods=["GET"], include_in_schema=False) if self.docs_path: - assert self.schema_path, "Schema path must be defined to use docs view" self.app.add_route(self.docs_path, self.docs_view, methods=["GET"], include_in_schema=False) def schema_view(self) -> http.OpenAPIResponse: return http.OpenAPIResponse(self.schema) def docs_view(self) -> http.HTMLResponse: - return http._FlamaTemplateResponse( - "schemas/docs.html", {"title": self.title, "schema_url": self.schema_path, "docs_url": self.docs_path} - ) + return http._FlamaTemplateResponse("schemas/docs.html", {"schema": self.schema}) diff --git a/flama/schemas/openapi.py b/flama/schemas/openapi.py index ddc7db82..4c758435 100644 --- a/flama/schemas/openapi.py +++ b/flama/schemas/openapi.py @@ -1,7 +1,7 @@ import dataclasses import typing as t -import flama.types +from flama import types __all__ = [ "Schema", @@ -32,7 +32,7 @@ "OpenAPISpec", ] -Schema = t.NewType("Schema", flama.types.JSONSchema) +Schema = t.NewType("Schema", types.JSONSchema) @dataclasses.dataclass(frozen=True) @@ -46,18 +46,31 @@ class Contact: url: t.Optional[str] = None email: t.Optional[str] = None + @classmethod + def from_spec(cls, spec: types.OpenAPISpecInfoContact, /) -> "Contact": + return cls(**spec) + @dataclasses.dataclass(frozen=True) class License: name: str + identifier: t.Optional[str] = None url: t.Optional[str] = None + @classmethod + def from_spec(cls, spec: types.OpenAPISpecInfoLicense, /) -> "License": + return cls(**spec) + @dataclasses.dataclass(frozen=True) class ExternalDocs: url: str description: t.Optional[str] = None + @classmethod + def from_spec(cls, spec: types.OpenAPISpecExternalDocs, /) -> "ExternalDocs": + return cls(**spec) + @dataclasses.dataclass(frozen=True) class Example: @@ -73,29 +86,77 @@ class Tag: description: t.Optional[str] = None externalDocs: t.Optional[ExternalDocs] = None + @classmethod + def from_spec(cls, spec: types.OpenAPISpecTag, /) -> "Tag": + return cls( + name=spec["name"], + description=spec.get("description"), + externalDocs=( + ExternalDocs.from_spec(t.cast(types.OpenAPISpecExternalDocs, spec.get("externalDocs"))) + if "externalDocs" in spec + else None + ), + ) + @dataclasses.dataclass(frozen=True) class Info: title: str version: str + summary: t.Optional[str] = None description: t.Optional[str] = None termsOfService: t.Optional[str] = None contact: t.Optional[Contact] = None license: t.Optional[License] = None + @classmethod + def from_spec(cls, spec: types.OpenAPISpecInfo) -> "Info": + return cls( + title=spec["title"], + version=spec["version"], + summary=spec.get("summary"), + description=spec.get("description"), + termsOfService=spec.get("termsOfService"), + contact=( + Contact.from_spec(t.cast(types.OpenAPISpecInfoContact, spec["contact"])) if "contact" in spec else None + ), + license=( + License.from_spec(t.cast(types.OpenAPISpecInfoLicense, spec["license"])) if "license" in spec else None + ), + ) + @dataclasses.dataclass(frozen=True) class ServerVariable: - enum: list[str] default: str + enum: t.Optional[list[str]] = None description: t.Optional[str] = None + @classmethod + def from_spec(cls, spec: types.OpenAPISpecServerVariable, /) -> "ServerVariable": + return cls(**spec) + @dataclasses.dataclass(frozen=True) class Server: url: str - variables: dict[str, ServerVariable] description: t.Optional[str] = None + variables: t.Optional[dict[str, ServerVariable]] = None + + @classmethod + def from_spec(cls, spec: types.OpenAPISpecServer, /) -> "Server": + return cls( + url=spec["url"], + description=spec.get("description"), + variables=( + { + name: ServerVariable.from_spec(t.cast(types.OpenAPISpecServerVariable, variable)) + for name, variable in spec["variables"].items() + } + if "variables" in spec and spec["variables"] + else None + ), + ) @dataclasses.dataclass(frozen=True) @@ -251,23 +312,16 @@ class OpenAPISpec: def __init__( self, - title: str, - version: str, - description: t.Optional[str] = None, - terms_of_service: t.Optional[str] = None, - contact: t.Optional[Contact] = None, - license: t.Optional[License] = None, + info: Info, + *, + servers: t.Optional[list[Server]] = None, + security: t.Optional[list[Security]] = None, + tags: t.Optional[list[Tag]] = None, + externalDocs: t.Optional[ExternalDocs] = None, ): self.spec = OpenAPI( openapi=self.OPENAPI_VERSION, - info=Info( - title=title, - version=version, - description=description, - termsOfService=terms_of_service, - contact=contact, - license=license, - ), + info=info, paths=Paths({}), components=Components( schemas={}, @@ -280,6 +334,32 @@ def __init__( links={}, callbacks={}, ), + servers=servers, + security=security, + tags=tags, + externalDocs=externalDocs, + ) + + @classmethod + def from_spec(cls, spec: types.OpenAPISpec, /) -> "OpenAPISpec": + return cls( + info=Info.from_spec(spec["info"]), + servers=( + [Server.from_spec(t.cast(types.OpenAPISpecServer, server)) for server in spec["servers"]] + if "servers" in spec and spec["servers"] + else None + ), + security=( + [Security(security) for security in spec["security"]] + if "security" in spec and spec["security"] + else None + ), + tags=[Tag.from_spec(tag) for tag in spec["tags"]] if "tags" in spec and spec["tags"] else None, + externalDocs=( + ExternalDocs.from_spec(t.cast(types.OpenAPISpecExternalDocs, spec.get("externalDocs"))) + if "externalDocs" in spec + else None + ), ) def add_path(self, path: str, item: Path): diff --git a/flama/types/__init__.py b/flama/types/__init__.py index 97e96399..14d312a0 100644 --- a/flama/types/__init__.py +++ b/flama/types/__init__.py @@ -4,4 +4,5 @@ from flama.types.http import * # noqa from flama.types.json import * # noqa from flama.types.pagination import * # noqa +from flama.types.openapi import * # noqa from flama.types.websockets import * # noqa diff --git a/flama/types/openapi.py b/flama/types/openapi.py new file mode 100644 index 00000000..c224ada6 --- /dev/null +++ b/flama/types/openapi.py @@ -0,0 +1,71 @@ +import typing as t + +from flama import compat + +__all__ = [ + "OpenAPISpecInfoContact", + "OpenAPISpecInfoLicense", + "OpenAPISpecInfo", + "OpenAPISpecServerVariable", + "OpenAPISpecServer", + "OpenAPISpecExternalDocs", + "OpenAPISpecTag", + "OpenAPISpecSecurity", + "OpenAPISpec", +] + + +class OpenAPISpecInfoContact(t.TypedDict): + name: str + url: str + email: str + + +class OpenAPISpecInfoLicense(t.TypedDict): + name: str + identifier: compat.NotRequired[t.Optional[str]] + url: compat.NotRequired[t.Optional[str]] + + +class OpenAPISpecInfo(t.TypedDict): + title: str + summary: compat.NotRequired[t.Optional[str]] + description: compat.NotRequired[t.Optional[str]] + termsOfService: compat.NotRequired[t.Optional[str]] + contact: compat.NotRequired[t.Optional[OpenAPISpecInfoContact]] + license: compat.NotRequired[t.Optional[OpenAPISpecInfoLicense]] + version: str + + +class OpenAPISpecServerVariable(t.TypedDict): + default: str + enum: compat.NotRequired[t.Optional[list[str]]] + description: compat.NotRequired[t.Optional[str]] + + +class OpenAPISpecServer(t.TypedDict): + url: str + description: compat.NotRequired[t.Optional[str]] + variables: compat.NotRequired[t.Optional[dict[str, OpenAPISpecServerVariable]]] + + +class OpenAPISpecExternalDocs(t.TypedDict): + url: str + description: compat.NotRequired[t.Optional[str]] + + +class OpenAPISpecTag(t.TypedDict): + name: str + description: compat.NotRequired[t.Optional[str]] + externalDocs: compat.NotRequired[t.Optional[OpenAPISpecExternalDocs]] + + +OpenAPISpecSecurity = dict[str, list[str]] + + +class OpenAPISpec(t.TypedDict): + info: OpenAPISpecInfo + servers: compat.NotRequired[t.Optional[list[OpenAPISpecServer]]] + security: compat.NotRequired[t.Optional[list[OpenAPISpecSecurity]]] + tags: compat.NotRequired[t.Optional[list[OpenAPISpecTag]]] + externalDocs: compat.NotRequired[t.Optional[OpenAPISpecExternalDocs]] diff --git a/tests/conftest.py b/tests/conftest.py index 573fec00..55ae80c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,6 +76,11 @@ def clear_pagination(): paginator.schemas = {} +@pytest.fixture +def openapi_spec(): + return {"info": {"title": "Foo", "version": "1.0.0", "description": "Bar"}} + + @pytest.fixture( scope="function", params=[ @@ -84,11 +89,9 @@ def clear_pagination(): pytest.param("marshmallow", id="marshmallow"), ], ) -def app(request): +def app(request, openapi_spec): return Flama( - title="Foo", - version="0.1", - description="Bar", + openapi=openapi_spec, schema="/schema/", docs="/docs/", modules={SQLAlchemyModule("sqlite+aiosqlite://")}, diff --git a/tests/schemas/test_data_structures.py b/tests/schemas/test_data_structures.py index a6ccd88e..8428e5f7 100644 --- a/tests/schemas/test_data_structures.py +++ b/tests/schemas/test_data_structures.py @@ -117,6 +117,8 @@ def schema_type( # noqa: C901 return t.Annotated[schemas.SchemaType, schemas.SchemaMetadata(foo_schema.schema)] elif request.param == "list_of_schema": return t.Annotated[list[schemas.SchemaType], schemas.SchemaMetadata(foo_schema.schema)] + elif request.param == "list_of_bare_schema": + return list[foo_schema.schema] elif request.param == "schema_partial": if app.schema.schema_library.lib in (typesystem,): pytest.skip("Library does not support optional partial schemas") @@ -142,6 +144,7 @@ def schema_type( # noqa: C901 pytest.param("schema", None, id="schema"), pytest.param("schema_partial", None, id="schema_partial"), pytest.param("list_of_schema", None, id="list_of_schema"), + pytest.param("list_of_bare_schema", None, id="list_of_bare_schema"), pytest.param("schema_nested", None, id="schema_nested"), pytest.param("schema_nested_optional", None, id="schema_nested_optional"), pytest.param("schema_nested_list", None, id="schema_nested_list"), @@ -194,6 +197,18 @@ def test_name(self): None, id="partial", ), + pytest.param( + "list_of_schema", + {"properties": {"name": {"type": "string"}}, "type": "object"}, + None, + id="list", + ), + pytest.param( + "list_of_bare_schema", + {"properties": {"name": {"type": "string"}}, "type": "object"}, + None, + id="list_bare", + ), pytest.param( "schema_nested", {"properties": {"foo": {"$ref": "#/components/schemas/Foo"}}, "type": "object"}, diff --git a/tests/schemas/test_generator.py b/tests/schemas/test_generator.py index 6b9927e1..abf07dc2 100644 --- a/tests/schemas/test_generator.py +++ b/tests/schemas/test_generator.py @@ -40,8 +40,8 @@ def registry(self, app): return SchemaRegistry() @pytest.fixture(scope="function") - def spec(self): - return openapi.OpenAPISpec(title="Foo", version="1.0.0") + def spec(self, openapi_spec): + return openapi.OpenAPISpec.from_spec(openapi_spec) def test_empty_init(self): assert SchemaRegistry() == {} @@ -1046,7 +1046,7 @@ def test_schema_info(self, app): schema = app.schema.schema["info"] assert schema["title"] == "Foo" - assert schema["version"] == "0.1" + assert schema["version"] == "1.0.0" assert schema["description"] == "Bar" def test_components_schemas(self, app, schemas): diff --git a/tests/schemas/test_modules.py b/tests/schemas/test_modules.py index 74bb4127..175d273e 100644 --- a/tests/schemas/test_modules.py +++ b/tests/schemas/test_modules.py @@ -8,21 +8,22 @@ class TestCaseSchemaModule: @pytest.fixture - def module(self): - m = SchemaModule("title", "0.1.0", "Foo", schema="/schema/", docs="/docs/") + def module(self, openapi_spec): + m = SchemaModule(openapi=openapi_spec, schema="/schema/", docs="/docs/") m.app = Flama() return m - def test_init(self): - title = "title" - version = "0.1.0" - description = "Foo" + def test_init(self, openapi_spec): + module = SchemaModule(openapi_spec, schema="/schema/", docs="/docs/") - module = SchemaModule(title, version, description, "/schema/", "/docs/") + assert module.openapi == openapi_spec - assert module.title == title - assert module.version == version - assert module.description == description + def test_register_schema(self, module, foo_schema): + assert module.schemas == {} + + module.register_schema("foo", foo_schema) + + assert module.schemas == {"foo": foo_schema} def test_schema_generator(self, module): with patch("flama.schemas.modules.SchemaGenerator") as generator_mock: @@ -30,9 +31,7 @@ def test_schema_generator(self, module): assert generator_mock.call_args_list == [ call( - title="title", - version="0.1.0", - description="Foo", + spec={"info": {"title": "Foo", "version": "1.0.0", "description": "Bar"}}, schemas={**schemas.schemas.SCHEMAS, **pagination.paginator.schemas}, ) ] @@ -40,7 +39,7 @@ def test_schema_generator(self, module): def test_schema(self, module): assert module.schema == { "openapi": "3.1.0", - "info": {"title": "title", "version": "0.1.0", "description": "Foo"}, + "info": {"title": "Foo", "version": "1.0.0", "description": "Bar"}, "paths": {}, "components": { "schemas": {}, @@ -56,19 +55,16 @@ def test_schema(self, module): } @pytest.mark.parametrize( - ["schema", "docs", "exception"], + ["schema", "docs"], ( - pytest.param(False, False, None, id="no_schema_no_docs"), - pytest.param(True, False, None, id="schema_but_no_docs"), - pytest.param( - False, True, AssertionError("Schema path must be defined to use docs view"), id="no_schema_but_docs" - ), - pytest.param(True, True, None, id="schema_and_docs"), + pytest.param(False, False, id="no_schema_no_docs"), + pytest.param(True, False, id="schema_but_no_docs"), + pytest.param(False, True, id="no_schema_but_docs"), + pytest.param(True, True, id="schema_and_docs"), ), - indirect=["exception"], ) - def test_add_routes(self, schema, docs, exception): - module = SchemaModule("", "", "", schema, docs) + def test_add_routes(self, openapi_spec, schema, docs): + module = SchemaModule(openapi_spec, schema=schema, docs=docs) module.app = Mock(Flama) expected_calls = [] if schema: @@ -76,9 +72,9 @@ def test_add_routes(self, schema, docs, exception): if docs: expected_calls.append(call(docs, module.docs_view, methods=["GET"], include_in_schema=False)) - with exception: - module.add_routes() - assert module.app.add_route.call_args_list == expected_calls + module.add_routes() + + assert module.app.add_route.call_args_list == expected_calls async def test_view_schema(self, client): response = await client.request("get", "/schema/") diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index d35d20b2..fbfc2fed 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -9,6 +9,7 @@ Example, ExternalDocs, Header, + Info, License, Link, MediaType, @@ -30,12 +31,14 @@ class TestCaseOpenAPISpec: @pytest.fixture def spec(self): return OpenAPISpec( - title="Title", - version="1.0.0", - description="Description", - terms_of_service="TOS", - contact=Contact(name="Contact name", url="https://contact.com", email="contact@contact.com"), - license=License(name="License name", url="https://license.com"), + info=Info( + title="Title", + version="1.0.0", + description="Description", + termsOfService="TOS", + contact=Contact(name="Contact name", url="https://contact.com", email="contact@contact.com"), + license=License(name="License name", url="https://license.com"), + ) ) @pytest.fixture @@ -180,44 +183,187 @@ def path(self, fake, operation, server, parameter): def callback(self, fake, path): return Callback({fake.word(): path}) - def test_init(self): - assert OpenAPISpec(title="Title", version="1.0.0") + @pytest.mark.parametrize( + ["spec", "result"], + [ + pytest.param( + {"info": {"title": "Title", "version": "1.0.0"}}, + { + "openapi": "3.1.0", + "info": {"title": "Title", "version": "1.0.0"}, + "components": { + "callbacks": {}, + "examples": {}, + "headers": {}, + "links": {}, + "parameters": {}, + "requestBodies": {}, + "responses": {}, + "schemas": {}, + "securitySchemes": {}, + }, + "paths": {}, + }, + id="simple", + ), + pytest.param( + { + "info": { + "title": "Example API", + "summary": "This is an example API specification", + "description": "A detailed description of the Example API, including usage and endpoints.", + "termsOfService": "https://example.com/terms", + "contact": { + "name": "API Support", + "url": "https://example.com/contact", + "email": "support@example.com", + }, + "license": { + "name": "MIT", + "identifier": "MIT", + "url": "https://opensource.org/licenses/MIT", + }, + "version": "1.0.0", + }, + "servers": [ + { + "url": "https://api.example.com/v1", + "description": "Production server", + "variables": { + "version": { + "default": "v1", + "enum": ["v1", "v2"], + "description": "API version", + } + }, + }, + { + "url": "https://staging-api.example.com", + "description": "Staging server", + }, + ], + "security": [ + { + "OAuth2": ["read", "write"], + "ApiKeyAuth": [], + } + ], + "tags": [ + { + "name": "users", + "description": "Operations related to users", + "externalDocs": { + "url": "https://docs.example.com/users", + "description": "User API documentation", + }, + }, + { + "name": "orders", + "description": "Operations related to orders", + }, + ], + "externalDocs": { + "url": "https://docs.example.com", + "description": "Full API documentation", + }, + }, + { + "components": { + "callbacks": {}, + "examples": {}, + "headers": {}, + "links": {}, + "parameters": {}, + "requestBodies": {}, + "responses": {}, + "schemas": {}, + "securitySchemes": {}, + }, + "externalDocs": { + "description": "Full API documentation", + "url": "https://docs.example.com", + }, + "info": { + "contact": { + "email": "support@example.com", + "name": "API Support", + "url": "https://example.com/contact", + }, + "description": "A detailed description of the Example API, including usage and endpoints.", + "license": { + "identifier": "MIT", + "name": "MIT", + "url": "https://opensource.org/licenses/MIT", + }, + "summary": "This is an example API specification", + "termsOfService": "https://example.com/terms", + "title": "Example API", + "version": "1.0.0", + }, + "openapi": "3.1.0", + "paths": {}, + "security": [ + { + "ApiKeyAuth": [], + "OAuth2": [ + "read", + "write", + ], + }, + ], + "servers": [ + { + "description": "Production server", + "url": "https://api.example.com/v1", + "variables": { + "version": { + "default": "v1", + "description": "API version", + "enum": [ + "v1", + "v2", + ], + }, + }, + }, + { + "description": "Staging server", + "url": "https://staging-api.example.com", + }, + ], + "tags": [ + { + "description": "Operations related to users", + "externalDocs": { + "description": "User API documentation", + "url": "https://docs.example.com/users", + }, + "name": "users", + }, + { + "description": "Operations related to orders", + "name": "orders", + }, + ], + }, + id="full", + ), + ], + ) + def test_from_spec_to_dict(self, spec, result): + assert OpenAPISpec.from_spec(spec).to_dict() == result + assert OpenAPISpec(info=Info(title="Title", version="1.0.0")) assert OpenAPISpec( - title="Title", - version="1.0.0", - description="Description", - terms_of_service="TOS", - contact=Contact(name="Contact name", url="Contact url", email="Contact email"), - license=License(name="License name", url="License url"), + info=Info( + title="Title", + version="1.0.0", + description="Description", + termsOfService="TOS", + contact=Contact(name="Contact name", url="Contact url", email="Contact email"), + license=License(name="License name", url="License url"), + ), ) - def test_asdict(self, spec): - expected_result = { - "openapi": "3.1.0", - "info": { - "title": "Title", - "version": "1.0.0", - "description": "Description", - "termsOfService": "TOS", - "contact": {"name": "Contact name", "url": "https://contact.com", "email": "contact@contact.com"}, - "license": {"name": "License name", "url": "https://license.com"}, - }, - "components": { - "callbacks": {}, - "examples": {}, - "headers": {}, - "links": {}, - "parameters": {}, - "requestBodies": {}, - "responses": {}, - "schemas": {}, - "securitySchemes": {}, - }, - "paths": {}, - } - - assert spec.to_dict() == expected_result - def test_add_path(self, spec, path): path_name = "foo" expected_result = spec.to_dict()