diff --git a/setup.py b/setup.py index 6893746cd7..49a9a2b615 100644 --- a/setup.py +++ b/setup.py @@ -94,6 +94,8 @@ def read(filename): "Products.CMFPlone>=5.2", "PyJWT>=1.7.0", "pytz", + "pyyaml", + "pyDantic", ], extras_require={"test": TEST_REQUIRES}, entry_points=""" diff --git a/src/plone/restapi/configure.zcml b/src/plone/restapi/configure.zcml index 2f79ec0c7a..7b0d123259 100644 --- a/src/plone/restapi/configure.zcml +++ b/src/plone/restapi/configure.zcml @@ -23,6 +23,15 @@ + + + diff --git a/src/plone/restapi/services/content/get.py b/src/plone/restapi/services/content/get.py index 5ab08d45c9..ae9dde331c 100644 --- a/src/plone/restapi/services/content/get.py +++ b/src/plone/restapi/services/content/get.py @@ -1,11 +1,84 @@ from plone.restapi.interfaces import ISerializeToJson from plone.restapi.services import Service from zope.component import queryMultiAdapter +from zope.publisher.interfaces.browser import IBrowserRequest +from plone.app.customerize import registration +from zope.component import getUtilitiesFor +from plone.dexterity.interfaces import IDexterityFTI +from plone import api +from zope.globalrequest import getRequest class ContentGet(Service): """Returns a serialized content object.""" + @classmethod + def __restapi_doc_component_schemas_extension__(cls): + doc = super(ContentGet, cls).__restapi_doc_component_schemas_extension__() + + # recupero le interfacce per cui il service รจ stato registrato + services = [ + i + for i in registration.getViews(IBrowserRequest) + if getattr(i.factory, "__name__", "") == cls.__name__ + ] + required_interfaces = [x.required[0] for x in services] + + # recupero i tipi di contenuto che implementano quelle interfacce + ftis = [] + for name, fti in getUtilitiesFor(IDexterityFTI): + if name == "Plone Site": + instance = api.portal.get() + else: + try: + instance = fti.constructInstance(api.portal.get(), id=name) + except Exception: + instance = getattr(api.portal.get(), "name", None) + + for interface in required_interfaces: + if interface.providedBy(instance): + ftis.append((fti, instance)) + break + + # chiamo il serializer registrato per ISerializeToJson per ogni tipo + # di contenuto + definition = {} + for fti in ftis: + serializer = queryMultiAdapter((fti[1], getRequest()), ISerializeToJson) + if method := getattr(serializer, "__restapi_doc_component_schema__", None): + definition.update(**method(fti[1], getRequest())) + + return definition + + @classmethod + def __restapi_doc__(cls): + return { + "get": { + "summary": "Content", + "description": "Content data", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "$ContextType", + } + } + }, + }, + "501": { + "description": "ServerError", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + }, + }, + }, + } + } + def reply(self): serializer = queryMultiAdapter((self.context, self.request), ISerializeToJson) diff --git a/src/plone/restapi/services/model.py b/src/plone/restapi/services/model.py new file mode 100644 index 0000000000..647ace26ea --- /dev/null +++ b/src/plone/restapi/services/model.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, Field + + +class ErrorDefinitionDTO(BaseModel): + type: str = Field(..., description="The type of error") + message: str = Field( + ..., description="A human-readable message describing the error" + ) + + +class ErrorOutputDTO(BaseModel): + error: ErrorDefinitionDTO diff --git a/src/plone/restapi/services/swagger/__init__.py b/src/plone/restapi/services/swagger/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/plone/restapi/services/swagger/configure.zcml b/src/plone/restapi/services/swagger/configure.zcml new file mode 100644 index 0000000000..50a0f0c378 --- /dev/null +++ b/src/plone/restapi/services/swagger/configure.zcml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/src/plone/restapi/services/swagger/get.py b/src/plone/restapi/services/swagger/get.py new file mode 100644 index 0000000000..39a2bd17ce --- /dev/null +++ b/src/plone/restapi/services/swagger/get.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +from plone import api +from plone.app.customerize import registration +from plone.restapi.services import Service +from plone.restapi.interfaces import ISerializeToJson +from Products.CMFCore.utils import getToolByName +from zope.component import getMultiAdapter +from zope.globalrequest import getRequest +from zope.publisher.interfaces.browser import IBrowserRequest + +import importlib +import logging +import transaction + +logger = logging.getLogger("Plone") + + +class SwaggerDefinitions(Service): + + def inject_schemas(self, doc, schemas): + def inject(d): + for k, v in d.items(): + if isinstance(v, dict): + inject(v) + else: + if k == "$ref" and "$" in v: + d[k] = schemas[v] + + inject(doc) + + def get_services_by_ct(self): + portal_types = getToolByName(api.portal.get(), "portal_types") + services_by_ct = {} + services = [ + i + for i in registration.getViews(IBrowserRequest) + if "plone.rest.zcml" in getattr(i.factory, "__module__", "") + ] + + for portal_type in portal_types.listTypeInfo(): + portal_type_services = [] + + if not getattr(portal_type, "klass", None): + continue + + module_name = ".".join(getattr(portal_type, "klass", ".").split(".")[:-1]) + module = importlib.import_module(module_name) + klass = getattr( + module, getattr(portal_type, "klass", ".").split(".")[-1], None + ) + + for service in services: + if service.required[0].implementedBy(klass): + portal_type_services.append(service) + + if portal_type_services: + services_by_ct[portal_type.id.replace(" ", "")] = portal_type_services + + return services_by_ct + + def get_doc_by_service(self, service): + # Supposed to be extended later + doc = getattr(service.factory, "__restapi_doc__", None) + if callable(doc): + return doc() + + def get_doc_schemas_by_service(self, service): + doc = getattr( + service.factory, "__restapi_doc_component_schemas_extension__", None + ) + if callable(doc): + return doc() + + def reply(self): + """ + Service to define an OpenApi definition for api + """ + + openapi_doc_boilerplate = { + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": api.portal.get().Title(), + "description": f"RESTApi description for a {api.portal.get().Title()} site", # noqa + }, + "servers": [ + { + "url": "http://localhost:8080/", + "description": "Site API", + "x-sandbox": False, + "x-healthCheck": { + "interval": "300", + "url": "https://demo.plone.org", + "timeout": "15", + }, + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + } + }, + "schemas": { + "ContentType": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title", + }, + }, + } + }, + } + }, + }, + "paths": {}, + "security": [{"bearerAuth": []}], + } + + with api.env.adopt_roles(["Manager"]): + for ct, services in self.get_services_by_ct().items(): + doc_template = {} + doc_template["parameters"] = [] + + path_parameter = { + "in": "path", + "name": ct, + "required": True, + "description": f"Path to the {ct}", + "schema": { + "type": "string", + "example": "", + }, + } + + doc_template["parameters"].append(path_parameter) + + for service in services: + service_doc = self.get_doc_by_service(service) + + if not service_doc: + logger.warning( + f"No documentation found for /{ct}/{'@' + service.name.split('@')[-1]}" # noqa + ) + continue + + doc = {**doc_template, **service_doc} + + api_name = ( + len(service.name.split("@")) > 1 + and "@" + service.name.split("@")[1] + or "" + ) + + openapi_doc_boilerplate["paths"][f"/{'{' + ct + '}'}/{api_name}"] = doc + + # Extend the components + component = self.get_doc_schemas_by_service(service) + + if component: + openapi_doc_boilerplate["components"]["schemas"].update(component) + + self.inject_schemas( + doc, + schemas={"$ContextType": f"#/components/schemas/{ct}"}, + ) + + transaction.abort() + + return openapi_doc_boilerplate