From c288c0e158e5c7e85cdc159de10065f5d4c35cf4 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 16 May 2024 17:59:39 +0200 Subject: [PATCH 01/21] openapi yaml generation --- src/plone/restapi/configure.zcml | 9 +++ src/plone/restapi/scripts/openapi_doc.yaml | 88 ++++++++++++++++++++++ src/plone/restapi/services/auth/login.py | 84 +++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/plone/restapi/scripts/openapi_doc.yaml diff --git a/src/plone/restapi/configure.zcml b/src/plone/restapi/configure.zcml index 2f79ec0c7a..39591da576 100644 --- a/src/plone/restapi/configure.zcml +++ b/src/plone/restapi/configure.zcml @@ -8,6 +8,15 @@ i18n_domain="plone.restapi" > + + Date: Thu, 16 May 2024 19:10:15 +0200 Subject: [PATCH 02/21] Script generator --- .../restapi/scripts/generate_json_schema.py | 193 ++++++++++++++++++ src/plone/restapi/scripts/openapi_doc.yaml | 88 -------- 2 files changed, 193 insertions(+), 88 deletions(-) create mode 100644 src/plone/restapi/scripts/generate_json_schema.py delete mode 100644 src/plone/restapi/scripts/openapi_doc.yaml diff --git a/src/plone/restapi/scripts/generate_json_schema.py b/src/plone/restapi/scripts/generate_json_schema.py new file mode 100644 index 0000000000..11fed213e8 --- /dev/null +++ b/src/plone/restapi/scripts/generate_json_schema.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +import logging +import importlib +import yaml +from plone.app.customerize import registration +from Products.CMFCore.utils import getToolByName +from zope.publisher.interfaces.browser import IBrowserRequest +from plone import api +from zope.schema import getFields +from collections import OrderedDict +from copy import copy +from plone.autoform.form import AutoExtensibleForm +from plone.autoform.interfaces import IParameterizedWidget +from plone.autoform.interfaces import WIDGETS_KEY +from plone.behavior.interfaces import IBehavior +from plone.dexterity.interfaces import IDexterityContent +from plone.dexterity.interfaces import IDexterityFTI +from plone.dexterity.utils import getAdditionalSchemata +from plone.i18n.normalizer import idnormalizer +from plone.restapi.interfaces import IFieldDeserializer +from plone.restapi.serializer.converters import IJsonCompatible +from plone.restapi.types.interfaces import IJsonSchemaProvider +from plone.supermodel import serializeModel +from plone.supermodel.interfaces import FIELDSETS_KEY +from plone.supermodel.utils import mergedTaggedValueDict +from plone.supermodel.utils import syncSchema +from Products.CMFCore.utils import getToolByName +from z3c.form import form as z3c_form +from zExceptions import BadRequest +from zope.component import getMultiAdapter +from zope.component import queryMultiAdapter +from zope.component import queryUtility +from zope.component.hooks import getSite +from zope.globalrequest import getRequest +from zope.i18n import translate +from zope.interface import implementer +from zope.schema.interfaces import IVocabularyFactory +from plone.restapi.types.utils import get_fieldsets, get_jsonschema_properties + +logger = logging.getLogger(__name__) + + +class Application(object): + """ """ + + @classmethod + def run(cls): + 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", + }, + "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": {}, + }, + "paths": {}, + } + + for ct, services in cls.get_services_by_ct().items(): + for service in services: + doc = cls.get_doc_by_service(service) + + if doc: + if not doc.get("parameters"): + doc["parameters"] = [] + + path_parameter = { + "in": "path", + "name": ct, + "required": True, + "description": f"Path to the {ct}", + "schema": { + "type": "string", + "example": "", + }, + } + + doc["parameters"].append(path_parameter) + + openapi_doc_boilerplate["paths"][ + f"/{'{' + ct + '}'}/{'@' + service.name.split('@')[1]}" + ] = doc + + else: + logger.warning( + f"No documentation found for /{ct}/{'@' + service.name.split('@')[-1]}" + ) + + # Extend the components + component = cls.get_doc_components_by_service(service) + + if component: + openapi_doc_boilerplate["components"]["schemas"].update(component) + + with open("openapi_doc.yaml", "w") as docfile: + docfile.write(cls.generate_yaml_by_doc(openapi_doc_boilerplate)) + + @classmethod + def generate_yaml_by_doc(cls, doc): + return yaml.dump(doc) + + @classmethod + def get_services_by_ct(cls): + 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): + logger.warning( + f"No documentation found for {getattr(portal_type, 'id', '[Can not pick up the name]')}" + ) + 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[klass.__name__] = portal_type_services + + return services_by_ct + + @classmethod + def get_doc_by_service(cls, service): + # Supposed to be extended later + doc = getattr(service.factory, "__restapi_doc__", None) + + return doc + + @classmethod + def get_doc_components_by_service(cls, service): + return getattr( + service.factory, "__restapi_doc_component_schemas_extension__", None + ) + + # @classmethod + # def get_ct_schemas(cls): + + # portal_types = getToolByName(api.portal.get(), "portal_types") + + # for fti in portal_types.listTypeInfo(): + # try: + # schema = fti.lookupSchema() + # except AttributeError: + # schema = None + # fieldsets = () + # additional_schemata = () + # else: + # additional_schemata = tuple(getAdditionalSchemata(portal_type=fti.id)) + # fieldsets = get_fieldsets( + # api.portal.get(), getRequest(), schema, additional_schemata + # ) + # import pdb + + # pdb.set_trace() + + +if __name__ == "__main__": + Application.run() diff --git a/src/plone/restapi/scripts/openapi_doc.yaml b/src/plone/restapi/scripts/openapi_doc.yaml deleted file mode 100644 index 5ca3c1355b..0000000000 --- a/src/plone/restapi/scripts/openapi_doc.yaml +++ /dev/null @@ -1,88 +0,0 @@ -components: - schemas: - ErrorResponse: - properties: - error: - properties: - message: - description: A human-readable message describing the error. - type: string - type: - description: The type of error. - type: string - type: object - type: object - securitySchemes: - bearerAuth: - bearerFormat: JWT - scheme: bearer - type: http -info: - description: RESTApi description for a Site site - title: Site - version: 1.0.0 -openapi: 3.0.0 -paths: - /{PloneSite}/@login: - parameters: - - description: Path to the PloneSite - in: path - name: PloneSite - required: true - schema: - example: '' - type: string - post: - description: A JWT token can be acquired by posting a user's credentials to - the @login endpoint - requestBody: - content: - application/json: - schema: - properties: - login: - example: admin - type: string - password: - example: admin - type: string - type: object - required: true - responses: - '200': - content: - application/json: - schema: - properties: - token: - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImZ1bGxuYW1lIjoiIn0.S9kUg8j-Iju0eaOpot7asXiZO8mlJX1fQVt9MPQpXBg - type: string - type: object - description: User succesfully authenticated - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: User input error - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: User input error - '501': - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: User input error - summary: Login endpoint -servers: -- description: Site API - url: http://localhost:8080/ - x-healthCheck: - interval: '300' - timeout: '15' - url: https://demo.plone.org - x-sandbox: false From 67319b037be56d299a7bbf635209c9ce1e869d02 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 16 May 2024 20:32:09 +0200 Subject: [PATCH 03/21] More code --- src/plone/restapi/serializer/dxcontent.py | 1 + src/plone/restapi/services/__init__.py | 21 +++++++++++++++++++ src/plone/restapi/services/auth/login.py | 25 ++--------------------- src/plone/restapi/services/content/get.py | 25 +++++++++++++++++++++++ 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py index 0154f1c91f..255e0a5701 100644 --- a/src/plone/restapi/serializer/dxcontent.py +++ b/src/plone/restapi/serializer/dxcontent.py @@ -129,6 +129,7 @@ def __call__(self, version=None, include_items=True): serializer = queryMultiAdapter( (field, obj, self.request), IFieldSerializer ) + value = serializer() result[json_compatible(name)] = value diff --git a/src/plone/restapi/services/__init__.py b/src/plone/restapi/services/__init__.py index 06809ef744..032988b446 100644 --- a/src/plone/restapi/services/__init__.py +++ b/src/plone/restapi/services/__init__.py @@ -12,6 +12,27 @@ class Service(RestService): """Base class for Plone REST API services""" + __restapi_doc_component_schemas_extension__ = { + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of error.", + }, + "message": { + "type": "string", + "description": "A human-readable message describing the error.", + }, + }, + } + }, + } + } + content_type = "application/json" def render(self): diff --git a/src/plone/restapi/services/auth/login.py b/src/plone/restapi/services/auth/login.py index 5214433997..865996d7f3 100644 --- a/src/plone/restapi/services/auth/login.py +++ b/src/plone/restapi/services/auth/login.py @@ -13,27 +13,6 @@ class Login(Service): """Handles login and returns a JSON web token (JWT).""" - __restapi_doc_component_schemas_extension__ = { - "ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "The type of error.", - }, - "message": { - "type": "string", - "description": "A human-readable message describing the error.", - }, - }, - } - }, - } - } - __restapi_doc__ = { "post": { "summary": "Login endpoint", @@ -78,7 +57,7 @@ class Login(Service): }, }, "401": { - "description": "User input error", + "description": "User unauthorized", "content": { "application/json": { "schema": {"$ref": "#/components/schemas/ErrorResponse"} @@ -86,7 +65,7 @@ class Login(Service): }, }, "501": { - "description": "User input error", + "description": "Server error", "content": { "application/json": { "schema": {"$ref": "#/components/schemas/ErrorResponse"} diff --git a/src/plone/restapi/services/content/get.py b/src/plone/restapi/services/content/get.py index 5ab08d45c9..83a91a9582 100644 --- a/src/plone/restapi/services/content/get.py +++ b/src/plone/restapi/services/content/get.py @@ -6,6 +6,31 @@ class ContentGet(Service): """Returns a serialized content object.""" + __restapi_doc__ = { + "get": { + "summary": "Content", + "description": "Content data", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {"type": "object", "$ref": "$ContextType"} + } + }, + }, + "501": { + "description": "ServerError", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + }, + }, + }, + } + } + def reply(self): serializer = queryMultiAdapter((self.context, self.request), ISerializeToJson) From 4b6f976e14c0325a7fd4b40871df3ea87f7cd2e8 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 16 May 2024 20:49:22 +0200 Subject: [PATCH 04/21] Update script --- .../restapi/scripts/generate_json_schema.py | 93 +++++++++++++------ 1 file changed, 67 insertions(+), 26 deletions(-) diff --git a/src/plone/restapi/scripts/generate_json_schema.py b/src/plone/restapi/scripts/generate_json_schema.py index 11fed213e8..5221258949 100644 --- a/src/plone/restapi/scripts/generate_json_schema.py +++ b/src/plone/restapi/scripts/generate_json_schema.py @@ -72,43 +72,68 @@ def run(cls): "bearerFormat": "JWT", } }, - "schemas": {}, + "schemas": { + "ContentType": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title", + }, + }, + } + }, + } + }, }, "paths": {}, } for ct, services in cls.get_services_by_ct().items(): - for service in services: - doc = cls.get_doc_by_service(service) - - if doc: - if not doc.get("parameters"): - doc["parameters"] = [] - - path_parameter = { - "in": "path", - "name": ct, - "required": True, - "description": f"Path to the {ct}", - "schema": { - "type": "string", - "example": "", - }, - } + doc_template = {} + doc_template["parameters"] = [] + + path_parameter = { + "in": "path", + "name": ct, + "required": True, + "description": f"Path to the {ct}", + "schema": { + "type": "string", + "example": "", + }, + } - doc["parameters"].append(path_parameter) + doc_template["parameters"].append(path_parameter) - openapi_doc_boilerplate["paths"][ - f"/{'{' + ct + '}'}/{'@' + service.name.split('@')[1]}" - ] = doc + for service in services: + service_doc = cls.get_doc_by_service(service) - else: + if not service_doc: logger.warning( f"No documentation found for /{ct}/{'@' + service.name.split('@')[-1]}" ) + continue + + doc = {**doc_template, **service_doc} + + cls.inject_schemas( + doc, schemas={"$ContextType": cls.get_schema_by_ct(ct)} + ) + + 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 = cls.get_doc_components_by_service(service) + component = cls.get_doc_schemas_by_service(service) if component: openapi_doc_boilerplate["components"]["schemas"].update(component) @@ -116,9 +141,25 @@ def run(cls): with open("openapi_doc.yaml", "w") as docfile: docfile.write(cls.generate_yaml_by_doc(openapi_doc_boilerplate)) + @classmethod + def get_schema_by_ct(cls, ct): + return "#/components/schemas/ContentType" + + @classmethod + def inject_schemas(cls, 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) + @classmethod def generate_yaml_by_doc(cls, doc): - return yaml.dump(doc) + return yaml.safe_dump(doc) @classmethod def get_services_by_ct(cls): @@ -162,7 +203,7 @@ def get_doc_by_service(cls, service): return doc @classmethod - def get_doc_components_by_service(cls, service): + def get_doc_schemas_by_service(cls, service): return getattr( service.factory, "__restapi_doc_component_schemas_extension__", None ) From 32a747a5c474881e22f5a30549ff2a2f39661a04 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 16 May 2024 21:40:51 +0200 Subject: [PATCH 05/21] Improves --- .../restapi/scripts/generate_json_schema.py | 62 +++++++----- src/plone/restapi/services/__init__.py | 38 ++++---- src/plone/restapi/services/auth/login.py | 96 ++++++++++--------- src/plone/restapi/services/content/get.py | 42 ++++---- 4 files changed, 128 insertions(+), 110 deletions(-) diff --git a/src/plone/restapi/scripts/generate_json_schema.py b/src/plone/restapi/scripts/generate_json_schema.py index 5221258949..8f51d18fd1 100644 --- a/src/plone/restapi/scripts/generate_json_schema.py +++ b/src/plone/restapi/scripts/generate_json_schema.py @@ -36,6 +36,8 @@ from zope.interface import implementer from zope.schema.interfaces import IVocabularyFactory from plone.restapi.types.utils import get_fieldsets, get_jsonschema_properties +from Products.CMFPlone.interfaces import IPloneSiteRoot +from plone.restapi.interfaces import ISerializeToJson logger = logging.getLogger(__name__) @@ -141,6 +143,8 @@ def run(cls): with open("openapi_doc.yaml", "w") as docfile: docfile.write(cls.generate_yaml_by_doc(openapi_doc_boilerplate)) + schemas = [i for i in cls.get_ct_schemas()] + @classmethod def get_schema_by_ct(cls, ct): return "#/components/schemas/ContentType" @@ -175,9 +179,6 @@ def get_services_by_ct(cls): portal_type_services = [] if not getattr(portal_type, "klass", None): - logger.warning( - f"No documentation found for {getattr(portal_type, 'id', '[Can not pick up the name]')}" - ) continue module_name = ".".join(getattr(portal_type, "klass", ".").split(".")[:-1]) @@ -199,35 +200,46 @@ def get_services_by_ct(cls): def get_doc_by_service(cls, service): # Supposed to be extended later doc = getattr(service.factory, "__restapi_doc__", None) + if callable(doc): + return doc() - return doc + return None @classmethod def get_doc_schemas_by_service(cls, service): - return getattr( + doc = getattr( service.factory, "__restapi_doc_component_schemas_extension__", None ) - # @classmethod - # def get_ct_schemas(cls): - - # portal_types = getToolByName(api.portal.get(), "portal_types") - - # for fti in portal_types.listTypeInfo(): - # try: - # schema = fti.lookupSchema() - # except AttributeError: - # schema = None - # fieldsets = () - # additional_schemata = () - # else: - # additional_schemata = tuple(getAdditionalSchemata(portal_type=fti.id)) - # fieldsets = get_fieldsets( - # api.portal.get(), getRequest(), schema, additional_schemata - # ) - # import pdb - - # pdb.set_trace() + if callable(doc): + return doc() + + return None + + @classmethod + def get_ct_schemas(cls): + + portal_types = getToolByName(api.portal.get(), "portal_types") + + for fti in portal_types.listTypeInfo(): + klass = getattr(fti, "klass", None) + + if klass: + klass = getattr( + importlib.import_module(".".join(klass.split(".")[:-1])), + klass.split(".")[-1], + ) + + if isinstance(api.portal.get(), klass): + obj = api.portal.get() + else: + obj = klass() + + # Doc retrieve here + yield getMultiAdapter((obj, getRequest()), ISerializeToJson) + + else: + logger.warning(f"Could not find a schema for {fti.id}") if __name__ == "__main__": diff --git a/src/plone/restapi/services/__init__.py b/src/plone/restapi/services/__init__.py index 032988b446..652bbb4c12 100644 --- a/src/plone/restapi/services/__init__.py +++ b/src/plone/restapi/services/__init__.py @@ -12,26 +12,28 @@ class Service(RestService): """Base class for Plone REST API services""" - __restapi_doc_component_schemas_extension__ = { - "ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "The type of error.", + @classmethod + def __restapi_doc_component_schemas_extension__(cls): + return { + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of error.", + }, + "message": { + "type": "string", + "description": "A human-readable message describing the error.", + }, }, - "message": { - "type": "string", - "description": "A human-readable message describing the error.", - }, - }, - } - }, + } + }, + } } - } content_type = "application/json" diff --git a/src/plone/restapi/services/auth/login.py b/src/plone/restapi/services/auth/login.py index 865996d7f3..7a792c42a7 100644 --- a/src/plone/restapi/services/auth/login.py +++ b/src/plone/restapi/services/auth/login.py @@ -13,68 +13,70 @@ class Login(Service): """Handles login and returns a JSON web token (JWT).""" - __restapi_doc__ = { - "post": { - "summary": "Login endpoint", - "description": "A JWT token can be acquired by posting a user's credentials to the @login endpoint", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "login": {"type": "string", "example": "admin"}, - "password": {"type": "string", "example": "admin"}, - }, - } - } - }, - }, - "responses": { - "200": { - "description": "User succesfully authenticated", + @classmethod + def __restapi_doc__(cls): + return { + "post": { + "summary": "Login endpoint", + "description": "A JWT token can be acquired by posting a user's credentials to the @login endpoint", + "requestBody": { + "required": True, "content": { "application/json": { "schema": { "type": "object", "properties": { - "token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImZ1bGxuYW1lIjoiIn0.S9kUg8j-Iju0eaOpot7asXiZO8mlJX1fQVt9MPQpXBg", - }, + "login": {"type": "string", "example": "admin"}, + "password": {"type": "string", "example": "admin"}, }, } } }, }, - "400": { - "description": "User input error", - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ErrorResponse"} - } + "responses": { + "200": { + "description": "User succesfully authenticated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImZ1bGxuYW1lIjoiIn0.S9kUg8j-Iju0eaOpot7asXiZO8mlJX1fQVt9MPQpXBg", + }, + }, + } + } + }, }, - }, - "401": { - "description": "User unauthorized", - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ErrorResponse"} - } + "400": { + "description": "User input error", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + }, }, - }, - "501": { - "description": "Server error", - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ErrorResponse"} - } + "401": { + "description": "User unauthorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + }, + }, + "501": { + "description": "Server error", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + }, }, }, - }, + } } - } def reply(self): data = json_body(self.request) diff --git a/src/plone/restapi/services/content/get.py b/src/plone/restapi/services/content/get.py index 83a91a9582..0a54d5c7eb 100644 --- a/src/plone/restapi/services/content/get.py +++ b/src/plone/restapi/services/content/get.py @@ -6,30 +6,32 @@ class ContentGet(Service): """Returns a serialized content object.""" - __restapi_doc__ = { - "get": { - "summary": "Content", - "description": "Content data", - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": {"type": "object", "$ref": "$ContextType"} - } + @classmethod + def __restapi_doc__(cls): + return { + "get": { + "summary": "Content", + "description": "Content data", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {"type": "object", "$ref": "$ContextType"} + } + }, }, - }, - "501": { - "description": "ServerError", - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ErrorResponse"} - } + "501": { + "description": "ServerError", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + }, }, }, - }, + } } - } def reply(self): serializer = queryMultiAdapter((self.context, self.request), ISerializeToJson) From f752347299d53c9efff367350d6d9756ad23c5c5 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 17 May 2024 13:07:02 +0200 Subject: [PATCH 06/21] JsonSerializerSchema --- src/plone/restapi/serializer/dxcontent.py | 95 +++++++++++++++++++---- src/plone/restapi/serializer/dxfields.py | 5 ++ 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py index 255e0a5701..a166027aca 100644 --- a/src/plone/restapi/serializer/dxcontent.py +++ b/src/plone/restapi/serializer/dxcontent.py @@ -55,6 +55,68 @@ def get_allow_discussion_value(context, request, result): @implementer(ISerializeToJson) @adapter(IDexterityContent, Interface) class SerializeToJson: + def __restapi_doc_component_schema__(self): + + return { + "PreviousItemSchema": { + "type": "any", + }, + "NextItemSchema": { + "type": "any", + }, + "WorkingCopy": {"type": "any"}, + "WorkingCopyOf": {"type": "any"}, + "LockInfo": {"type": "any"}, + "ExpandableItems": {"type": "any"}, + "TargetUrl": {"type": "any"}, + self.context.portal_type: { + "type": "object", + "properties": { + "@id": {"type": "string"}, + "id": {"type": "string"}, + "@type": {"type": "string"}, + "type_title": {"type": "string"}, + "parent": {"type": "string"}, + "created": {"type": "string"}, + "modified": {"type": "string"}, + "review_state": {"type": "string"}, + "UID": {"type": "string"}, + "version": {"type": "string"}, + "layout": {"type": "string"}, + "is_folderish": {"type": "boolean"}, + "previous_item": { + "type": "array", + "items": {"$ref": "#/components/schemas/PreviousItemSchema"}, + }, + "next_item": { + "type": "array", + "items": {"$ref": "#/components/schemas/NextItemSchema"}, + }, + "working_copy": {"$ref": "#/components/schemas/WorkingCopy"}, + "working_copy_of": {"$ref": "#/components/schemas/WorkingCopyOf"}, + "lock": {"$ref": "#/components/schemas/LockInfo"}, + "@components": {"$ref": "#/components/schemas/ExpandableItems"}, + **(self._get_context_field_schema()), + "targetUrl": {"$ref": "#/components/schemas/TargetUrl"}, + "allow_discussion": {"type": "bool"}, + }, + }, + } + + def _get_context_field_schema(self): + schema = {} + obj = self.getVersion(version="current") + + for name, field in self._get_context_field_serializers(obj): + method = getattr(field, "__restapi_schema_json_type__", None) + + if callable(method): + schema[name] = field.__restapi_schema_json_type__() + else: + schema[name] = {type: "any"} + + return schema + def __init__(self, context, request): self.context = context self.request = request @@ -117,21 +179,9 @@ def __call__(self, version=None, include_items=True): # Insert expandable elements result.update(expandable_elements(self.context, self.request)) - # Insert field values - for schema in iterSchemata(self.context): - read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY) - - for name, field in getFields(schema).items(): - if not self.check_permission(read_permissions.get(name), obj): - continue - - # serialize the field - serializer = queryMultiAdapter( - (field, obj, self.request), IFieldSerializer - ) - - value = serializer() - result[json_compatible(name)] = value + for name, serializer in self._get_context_field_serializers(obj): + value = serializer() + result[json_compatible(name)] = value target_url = getMultiAdapter( (self.context, self.request), IObjectPrimaryFieldTarget @@ -143,6 +193,21 @@ def __call__(self, version=None, include_items=True): return result + def _get_context_field_serializers(self, obj): + # Insert field values + for schema in iterSchemata(self.context): + read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY) + + for name, field in getFields(schema).items(): + if not self.check_permission(read_permissions.get(name), obj): + continue + + # serialize the field + yield ( + name, + queryMultiAdapter((field, obj, self.request), IFieldSerializer), + ) + def _get_workflow_state(self, obj): wftool = getToolByName(self.context, "portal_workflow") review_state = wftool.getInfoFor(ob=obj, name="review_state", default=None) diff --git a/src/plone/restapi/serializer/dxfields.py b/src/plone/restapi/serializer/dxfields.py index c63c7fe80e..0f1895d649 100644 --- a/src/plone/restapi/serializer/dxfields.py +++ b/src/plone/restapi/serializer/dxfields.py @@ -21,6 +21,7 @@ from zope.schema.interfaces import IField from zope.schema.interfaces import ITextLine from zope.schema.interfaces import IVocabularyTokenized +from zope.schema import _bootstrapfields import logging @@ -36,6 +37,10 @@ def __init__(self, field, context, request): self.request = request self.field = field + def __restapi_schema_json_type__(self): + type = {str: "string", bool: "bool", int: "integer"}.get(self.field._type) + return {"type": type or "any"} + def __call__(self): return json_compatible(self.get_value()) From f5d9a93850cde6ff1d013b0dd37953783da92b09 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 17 May 2024 15:51:37 +0200 Subject: [PATCH 07/21] Update schemas --- src/plone/restapi/serializer/dxcontent.py | 25 ++++++- src/plone/restapi/serializer/site.py | 89 +++++++++++++++++++---- 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py index a166027aca..fd6edb5a9a 100644 --- a/src/plone/restapi/serializer/dxcontent.py +++ b/src/plone/restapi/serializer/dxcontent.py @@ -69,6 +69,7 @@ def __restapi_doc_component_schema__(self): "LockInfo": {"type": "any"}, "ExpandableItems": {"type": "any"}, "TargetUrl": {"type": "any"}, + "ParentShema": {"type": "any"}, self.context.portal_type: { "type": "object", "properties": { @@ -76,7 +77,9 @@ def __restapi_doc_component_schema__(self): "id": {"type": "string"}, "@type": {"type": "string"}, "type_title": {"type": "string"}, - "parent": {"type": "string"}, + "parent": { + "items": {"$ref": "#/components/schemas/ParentShema"}, + }, "created": {"type": "string"}, "modified": {"type": "string"}, "review_state": {"type": "string"}, @@ -232,6 +235,26 @@ def check_permission(self, permission_name, obj): @implementer(ISerializeToJson) @adapter(IDexterityContainer, Interface) class SerializeFolderToJson(SerializeToJson): + def __restapi_doc_component_schema__(self): + result = super().__restapi_doc_component_schema__() + + ct = result[self.context.portal_type] + + result.update({"BrainItem": {"type": "any"}}) + ct.update( + { + "is_folderish": {"type": "boolean"}, + "items_total": {"type": "integer"}, + "batching": {"type": "any"}, + "items": { + "type": "array", + "items": {"$ref": "#/components/schemas/BrainItem"}, + }, + } + ) + + return result + def _build_query(self): path = "/".join(self.context.getPhysicalPath()) query = { diff --git a/src/plone/restapi/serializer/site.py b/src/plone/restapi/serializer/site.py index 11c5a0a2a3..7b04eb4234 100644 --- a/src/plone/restapi/serializer/site.py +++ b/src/plone/restapi/serializer/site.py @@ -40,6 +40,76 @@ def __init__(self, context, request): self.context = context self.request = request + def __restapi_doc_component_schema__(self): + + return { + "ParentShema": {"type": "any"}, + "LockInfo": {"type": "any"}, + "Block": {"type": "any"}, + "BlocksLayout": {"type": "any"}, + "PloneSite": { + "type": "object", + "properties": { + "@id": {"type": "string"}, + "id": {"type": "string"}, + "@type": {"type": "string"}, + "type_title": {"type": "string"}, + "title": {"type": "string"}, + "parent": { + "items": {"$ref": "#/components/schemas/ParentShema"}, + }, + "is_folderish": {"type": "boolean"}, + "description": {"type": "string"}, + "review_state": {"type": "string"}, + **{self._get_context_field_schema}, + "lock": {"$ref": "#/components/schemas/LockInfo"}, + "blocks": { + "type": "array", + "items": {"$ref": "#/components/schemas/Block"}, + }, + "blocks_layout": { + "type": "object", + "schema": {"$ref": "#/components/schemas/BlocksLayout"}, + }, + "items_total": {"type": "integer"}, + "batching": {"type": "any"}, + "items": { + "type": "array", + "items": {"$ref": "#/components/schemas/BrainItem"}, + }, + "allow_discussion": {"type": "bool"}, + }, + }, + } + + def _get_context_field_schema(self): + for name, field in self._get_context_field_serializers(): + schema = {} + method = getattr(field, "__restapi_schema_json_type__", None) + + if callable(method): + schema[name] = field.__restapi_schema_json_type__() + else: + schema[name] = {type: "any"} + + return schema + + def _get_context_field_serializers(self): + for schema in iterSchemata(self.context): + read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY) + + for name, field in getFields(schema).items(): + if not self.check_permission(read_permissions.get(name), self.context): + continue + + # serialize the field + yield ( + name, + queryMultiAdapter( + (field, self.context, self.request), IFieldSerializer + ), + ) + def _build_query(self): path = "/".join(self.context.getPhysicalPath()) query = { @@ -80,22 +150,9 @@ def __call__(self, version=None): ob=self.context, name="review_state", default=None ) - # Insert Plone Site DX root field values - for schema in iterSchemata(self.context): - read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY) - - for name, field in getFields(schema).items(): - if not self.check_permission( - read_permissions.get(name), self.context - ): - continue - - # serialize the field - serializer = queryMultiAdapter( - (field, self.context, self.request), IFieldSerializer - ) - value = serializer() - result[json_compatible(name)] = value + for name, serializer in self._get_context_field_serializers(): + value = serializer() + result[json_compatible(name)] = value # Insert locking information result.update({"lock": lock_info(self.context)}) From b94008c276eabb3bb9256c4a32650d141ed27c71 Mon Sep 17 00:00:00 2001 From: Filippo Campi Date: Fri, 17 May 2024 16:04:00 +0200 Subject: [PATCH 08/21] improved schema generation for ISerializeToJSON adapters --- setup.py | 1 + .../restapi/scripts/generate_json_schema.py | 1 + src/plone/restapi/serializer/dxcontent.py | 94 +++++++++++++------ src/plone/restapi/services/content/get.py | 59 +++++++++++- 4 files changed, 122 insertions(+), 33 deletions(-) diff --git a/setup.py b/setup.py index 6893746cd7..fa0db2def8 100644 --- a/setup.py +++ b/setup.py @@ -94,6 +94,7 @@ def read(filename): "Products.CMFPlone>=5.2", "PyJWT>=1.7.0", "pytz", + "pyyaml", ], extras_require={"test": TEST_REQUIRES}, entry_points=""" diff --git a/src/plone/restapi/scripts/generate_json_schema.py b/src/plone/restapi/scripts/generate_json_schema.py index 8f51d18fd1..812ae00f8a 100644 --- a/src/plone/restapi/scripts/generate_json_schema.py +++ b/src/plone/restapi/scripts/generate_json_schema.py @@ -75,6 +75,7 @@ def run(cls): } }, "schemas": { + # TODO andrà sovrascritto "ContentType": { "type": "object", "properties": { diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py index fd6edb5a9a..704efd092a 100644 --- a/src/plone/restapi/serializer/dxcontent.py +++ b/src/plone/restapi/serializer/dxcontent.py @@ -55,7 +55,28 @@ def get_allow_discussion_value(context, request, result): @implementer(ISerializeToJson) @adapter(IDexterityContent, Interface) class SerializeToJson: - def __restapi_doc_component_schema__(self): + @classmethod + def __restapi_doc_component_schema__(cls, context, request): + fields_adapter = [] + for schema in iterSchemata(context): + for name, field in getFields(schema).items(): + fields_adapter.append( + ( + name, + queryMultiAdapter( + (field, context, request), IFieldSerializer + ), + ) + ) + + schema = {} + for name, field in fields_adapter: + method = getattr(field, "__restapi_schema_json_type__", None) + + if callable(method): + schema[name] = field.__restapi_schema_json_type__() + else: + schema[name] = {type: "any"} return { "PreviousItemSchema": { @@ -70,7 +91,7 @@ def __restapi_doc_component_schema__(self): "ExpandableItems": {"type": "any"}, "TargetUrl": {"type": "any"}, "ParentShema": {"type": "any"}, - self.context.portal_type: { + context.portal_type: { "type": "object", "properties": { "@id": {"type": "string"}, @@ -89,37 +110,33 @@ def __restapi_doc_component_schema__(self): "is_folderish": {"type": "boolean"}, "previous_item": { "type": "array", - "items": {"$ref": "#/components/schemas/PreviousItemSchema"}, + "items": { + "$ref": "#/components/schemas/PreviousItemSchema" + }, }, "next_item": { "type": "array", - "items": {"$ref": "#/components/schemas/NextItemSchema"}, + "items": { + "$ref": "#/components/schemas/NextItemSchema" + }, + }, + "working_copy": { + "$ref": "#/components/schemas/WorkingCopy" + }, + "working_copy_of": { + "$ref": "#/components/schemas/WorkingCopyOf" }, - "working_copy": {"$ref": "#/components/schemas/WorkingCopy"}, - "working_copy_of": {"$ref": "#/components/schemas/WorkingCopyOf"}, "lock": {"$ref": "#/components/schemas/LockInfo"}, - "@components": {"$ref": "#/components/schemas/ExpandableItems"}, - **(self._get_context_field_schema()), + "@components": { + "$ref": "#/components/schemas/ExpandableItems" + }, + **schema, "targetUrl": {"$ref": "#/components/schemas/TargetUrl"}, "allow_discussion": {"type": "bool"}, }, }, } - def _get_context_field_schema(self): - schema = {} - obj = self.getVersion(version="current") - - for name, field in self._get_context_field_serializers(obj): - method = getattr(field, "__restapi_schema_json_type__", None) - - if callable(method): - schema[name] = field.__restapi_schema_json_type__() - else: - schema[name] = {type: "any"} - - return schema - def __init__(self, context, request): self.context = context self.request = request @@ -162,7 +179,10 @@ def __call__(self, version=None, include_items=True): try: nextprevious = NextPrevious(obj) result.update( - {"previous_item": nextprevious.previous, "next_item": nextprevious.next} + { + "previous_item": nextprevious.previous, + "next_item": nextprevious.next, + } ) except ValueError: # If we're serializing an old version that was renamed or moved, @@ -174,7 +194,9 @@ def __call__(self, version=None, include_items=True): baseline, working_copy = WorkingCopyInfo( self.context ).get_working_copy_info() - result.update({"working_copy": working_copy, "working_copy_of": baseline}) + result.update( + {"working_copy": working_copy, "working_copy_of": baseline} + ) # Insert locking information result.update({"lock": lock_info(obj)}) @@ -199,7 +221,9 @@ def __call__(self, version=None, include_items=True): def _get_context_field_serializers(self, obj): # Insert field values for schema in iterSchemata(self.context): - read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY) + read_permissions = mergedTaggedValueDict( + schema, READ_PERMISSIONS_KEY + ) for name, field in getFields(schema).items(): if not self.check_permission(read_permissions.get(name), obj): @@ -208,12 +232,16 @@ def _get_context_field_serializers(self, obj): # serialize the field yield ( name, - queryMultiAdapter((field, obj, self.request), IFieldSerializer), + queryMultiAdapter( + (field, obj, self.request), IFieldSerializer + ), ) def _get_workflow_state(self, obj): wftool = getToolByName(self.context, "portal_workflow") - review_state = wftool.getInfoFor(ob=obj, name="review_state", default=None) + review_state = wftool.getInfoFor( + ob=obj, name="review_state", default=None + ) return review_state def check_permission(self, permission_name, obj): @@ -289,7 +317,9 @@ def __call__(self, version=None, include_items=True): )(fullobjects=True)["items"] else: result["items"] = [ - getMultiAdapter((brain, self.request), ISerializeToJsonSummary)() + getMultiAdapter( + (brain, self.request), ISerializeToJsonSummary + )() for brain in batch ] return result @@ -307,10 +337,14 @@ def __init__(self, context, request): def __call__(self): primary_field_name = self.get_primary_field_name() for schema in iterSchemata(self.context): - read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY) + read_permissions = mergedTaggedValueDict( + schema, READ_PERMISSIONS_KEY + ) for name, field in getFields(schema).items(): - if not self.check_permission(read_permissions.get(name), self.context): + if not self.check_permission( + read_permissions.get(name), self.context + ): continue if name != primary_field_name: diff --git a/src/plone/restapi/services/content/get.py b/src/plone/restapi/services/content/get.py index 0a54d5c7eb..57759902e6 100644 --- a/src/plone/restapi/services/content/get.py +++ b/src/plone/restapi/services/content/get.py @@ -1,11 +1,57 @@ 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: + instance = fti.constructInstance(api.portal.get(), id=name) + + 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 { @@ -17,7 +63,10 @@ def __restapi_doc__(cls): "description": "Success", "content": { "application/json": { - "schema": {"type": "object", "$ref": "$ContextType"} + "schema": { + "type": "object", + "$ref": "$ContextType", + } } }, }, @@ -25,7 +74,9 @@ def __restapi_doc__(cls): "description": "ServerError", "content": { "application/json": { - "schema": {"$ref": "#/components/schemas/ErrorResponse"} + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } }, }, @@ -34,7 +85,9 @@ def __restapi_doc__(cls): } def reply(self): - serializer = queryMultiAdapter((self.context, self.request), ISerializeToJson) + serializer = queryMultiAdapter( + (self.context, self.request), ISerializeToJson + ) if serializer is None: self.request.response.setStatus(501) From d6b0af8651b0d5b74d5f6889c59d541d67be2da7 Mon Sep 17 00:00:00 2001 From: Filippo Campi Date: Fri, 17 May 2024 16:10:19 +0200 Subject: [PATCH 09/21] all doc generators now are classmethod --- src/plone/restapi/serializer/dxcontent.py | 10 ++++-- src/plone/restapi/serializer/site.py | 42 ++++++++++++++--------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py index 704efd092a..413c1fd392 100644 --- a/src/plone/restapi/serializer/dxcontent.py +++ b/src/plone/restapi/serializer/dxcontent.py @@ -263,10 +263,14 @@ def check_permission(self, permission_name, obj): @implementer(ISerializeToJson) @adapter(IDexterityContainer, Interface) class SerializeFolderToJson(SerializeToJson): - def __restapi_doc_component_schema__(self): - result = super().__restapi_doc_component_schema__() - ct = result[self.context.portal_type] + @classmethod + def __restapi_doc_component_schema__(cls, context, request): + result = super( + cls, SerializeFolderToJson + ).__restapi_doc_component_schema__(context, request) + + ct: dict = result[context.portal_type] result.update({"BrainItem": {"type": "any"}}) ct.update( diff --git a/src/plone/restapi/serializer/site.py b/src/plone/restapi/serializer/site.py index 7b04eb4234..fd1b9662c3 100644 --- a/src/plone/restapi/serializer/site.py +++ b/src/plone/restapi/serializer/site.py @@ -36,11 +36,29 @@ @implementer(ISerializeToJson) @adapter(IPloneSiteRoot, Interface) class SerializeSiteRootToJson: - def __init__(self, context, request): - self.context = context - self.request = request - def __restapi_doc_component_schema__(self): + @classmethod + def __restapi_doc_component_schema__(cls, context, request): + fields_adapter = [] + for schema in iterSchemata(context): + for name, field in getFields(schema).items(): + fields_adapter.append( + ( + name, + queryMultiAdapter( + (field, context, request), IFieldSerializer + ), + ) + ) + + schema = {} + for name, field in fields_adapter: + method = getattr(field, "__restapi_schema_json_type__", None) + + if callable(method): + schema[name] = field.__restapi_schema_json_type__() + else: + schema[name] = {type: "any"} return { "ParentShema": {"type": "any"}, @@ -61,7 +79,7 @@ def __restapi_doc_component_schema__(self): "is_folderish": {"type": "boolean"}, "description": {"type": "string"}, "review_state": {"type": "string"}, - **{self._get_context_field_schema}, + **schema, "lock": {"$ref": "#/components/schemas/LockInfo"}, "blocks": { "type": "array", @@ -82,17 +100,9 @@ def __restapi_doc_component_schema__(self): }, } - def _get_context_field_schema(self): - for name, field in self._get_context_field_serializers(): - schema = {} - method = getattr(field, "__restapi_schema_json_type__", None) - - if callable(method): - schema[name] = field.__restapi_schema_json_type__() - else: - schema[name] = {type: "any"} - - return schema + def __init__(self, context, request): + self.context = context + self.request = request def _get_context_field_serializers(self): for schema in iterSchemata(self.context): From 34e0ca951a10837a0938b4b39e4e5fabc32a89fa Mon Sep 17 00:00:00 2001 From: Filippo Campi Date: Fri, 17 May 2024 16:16:31 +0200 Subject: [PATCH 10/21] fixed object creation --- src/plone/restapi/services/content/get.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plone/restapi/services/content/get.py b/src/plone/restapi/services/content/get.py index 57759902e6..cf6db1beaf 100644 --- a/src/plone/restapi/services/content/get.py +++ b/src/plone/restapi/services/content/get.py @@ -32,7 +32,10 @@ def __restapi_doc_component_schemas_extension__(cls): if name == "Plone Site": instance = api.portal.get() else: - instance = fti.constructInstance(api.portal.get(), id=name) + 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): From f90d2bfc0a4fa03fee22738f7fdb9099c4abf315 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 17 May 2024 16:20:32 +0200 Subject: [PATCH 11/21] Update script --- .../restapi/scripts/generate_json_schema.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/plone/restapi/scripts/generate_json_schema.py b/src/plone/restapi/scripts/generate_json_schema.py index 812ae00f8a..e9ad5992f1 100644 --- a/src/plone/restapi/scripts/generate_json_schema.py +++ b/src/plone/restapi/scripts/generate_json_schema.py @@ -123,10 +123,6 @@ def run(cls): doc = {**doc_template, **service_doc} - cls.inject_schemas( - doc, schemas={"$ContextType": cls.get_schema_by_ct(ct)} - ) - api_name = ( len(service.name.split("@")) > 1 and "@" + service.name.split("@")[1] @@ -141,14 +137,27 @@ def run(cls): if component: openapi_doc_boilerplate["components"]["schemas"].update(component) + cls.inject_schemas( + doc, + schemas={ + "$ContextType": cls.get_schema_by_ct( + ct, openapi_doc_boilerplate["components"]["schemas"] + ) + }, + ) + with open("openapi_doc.yaml", "w") as docfile: docfile.write(cls.generate_yaml_by_doc(openapi_doc_boilerplate)) - schemas = [i for i in cls.get_ct_schemas()] - @classmethod - def get_schema_by_ct(cls, ct): - return "#/components/schemas/ContentType" + def get_schema_by_ct(cls, ct, schemas): + schema = schemas.get(ct) + + if not schema: + logger.warning(f"Not found schema for {ct}") + return + + return schema @classmethod def inject_schemas(cls, doc, schemas): From 04bb73471057646214fc7926cbe48c3ed6827123 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 17 May 2024 16:36:20 +0200 Subject: [PATCH 12/21] Update script --- .../restapi/scripts/generate_json_schema.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/plone/restapi/scripts/generate_json_schema.py b/src/plone/restapi/scripts/generate_json_schema.py index e9ad5992f1..302f4df9d1 100644 --- a/src/plone/restapi/scripts/generate_json_schema.py +++ b/src/plone/restapi/scripts/generate_json_schema.py @@ -93,6 +93,7 @@ def run(cls): }, }, "paths": {}, + "security": [{"bearerAuth": []}], } for ct, services in cls.get_services_by_ct().items(): @@ -139,26 +140,12 @@ def run(cls): cls.inject_schemas( doc, - schemas={ - "$ContextType": cls.get_schema_by_ct( - ct, openapi_doc_boilerplate["components"]["schemas"] - ) - }, + schemas={"$ContextType": f"#/components/schemas/{ct}"}, ) with open("openapi_doc.yaml", "w") as docfile: docfile.write(cls.generate_yaml_by_doc(openapi_doc_boilerplate)) - @classmethod - def get_schema_by_ct(cls, ct, schemas): - schema = schemas.get(ct) - - if not schema: - logger.warning(f"Not found schema for {ct}") - return - - return schema - @classmethod def inject_schemas(cls, doc, schemas): def inject(d): From 24a4865d185b50840edad3460eb14c5a88506b5a Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 17 May 2024 16:48:01 +0200 Subject: [PATCH 13/21] Fix logics --- .../restapi/scripts/generate_json_schema.py | 2 +- src/plone/restapi/serializer/dxcontent.py | 65 ++++++------------- 2 files changed, 22 insertions(+), 45 deletions(-) diff --git a/src/plone/restapi/scripts/generate_json_schema.py b/src/plone/restapi/scripts/generate_json_schema.py index 302f4df9d1..5beb753369 100644 --- a/src/plone/restapi/scripts/generate_json_schema.py +++ b/src/plone/restapi/scripts/generate_json_schema.py @@ -189,7 +189,7 @@ def get_services_by_ct(cls): portal_type_services.append(service) if portal_type_services: - services_by_ct[klass.__name__] = portal_type_services + services_by_ct[portal_type.id] = portal_type_services return services_by_ct diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py index 413c1fd392..cd0659dc11 100644 --- a/src/plone/restapi/serializer/dxcontent.py +++ b/src/plone/restapi/serializer/dxcontent.py @@ -17,6 +17,7 @@ from plone.restapi.serializer.nextprev import NextPrevious from plone.restapi.services.locking import lock_info from plone.restapi.serializer.utils import get_portal_type_title +from plone import api from plone.rfc822.interfaces import IPrimaryFieldInfo from plone.supermodel.utils import mergedTaggedValueDict from Products.CMFCore.utils import getToolByName @@ -58,14 +59,13 @@ class SerializeToJson: @classmethod def __restapi_doc_component_schema__(cls, context, request): fields_adapter = [] + portal_types = getToolByName(api.portal.get(), "portal_types") for schema in iterSchemata(context): for name, field in getFields(schema).items(): fields_adapter.append( ( name, - queryMultiAdapter( - (field, context, request), IFieldSerializer - ), + queryMultiAdapter((field, context, request), IFieldSerializer), ) ) @@ -91,7 +91,7 @@ def __restapi_doc_component_schema__(cls, context, request): "ExpandableItems": {"type": "any"}, "TargetUrl": {"type": "any"}, "ParentShema": {"type": "any"}, - context.portal_type: { + portal_types.get(context.portal_type).id: { "type": "object", "properties": { "@id": {"type": "string"}, @@ -110,26 +110,16 @@ def __restapi_doc_component_schema__(cls, context, request): "is_folderish": {"type": "boolean"}, "previous_item": { "type": "array", - "items": { - "$ref": "#/components/schemas/PreviousItemSchema" - }, + "items": {"$ref": "#/components/schemas/PreviousItemSchema"}, }, "next_item": { "type": "array", - "items": { - "$ref": "#/components/schemas/NextItemSchema" - }, - }, - "working_copy": { - "$ref": "#/components/schemas/WorkingCopy" - }, - "working_copy_of": { - "$ref": "#/components/schemas/WorkingCopyOf" + "items": {"$ref": "#/components/schemas/NextItemSchema"}, }, + "working_copy": {"$ref": "#/components/schemas/WorkingCopy"}, + "working_copy_of": {"$ref": "#/components/schemas/WorkingCopyOf"}, "lock": {"$ref": "#/components/schemas/LockInfo"}, - "@components": { - "$ref": "#/components/schemas/ExpandableItems" - }, + "@components": {"$ref": "#/components/schemas/ExpandableItems"}, **schema, "targetUrl": {"$ref": "#/components/schemas/TargetUrl"}, "allow_discussion": {"type": "bool"}, @@ -194,9 +184,7 @@ def __call__(self, version=None, include_items=True): baseline, working_copy = WorkingCopyInfo( self.context ).get_working_copy_info() - result.update( - {"working_copy": working_copy, "working_copy_of": baseline} - ) + result.update({"working_copy": working_copy, "working_copy_of": baseline}) # Insert locking information result.update({"lock": lock_info(obj)}) @@ -221,9 +209,7 @@ def __call__(self, version=None, include_items=True): def _get_context_field_serializers(self, obj): # Insert field values for schema in iterSchemata(self.context): - read_permissions = mergedTaggedValueDict( - schema, READ_PERMISSIONS_KEY - ) + read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY) for name, field in getFields(schema).items(): if not self.check_permission(read_permissions.get(name), obj): @@ -232,16 +218,12 @@ def _get_context_field_serializers(self, obj): # serialize the field yield ( name, - queryMultiAdapter( - (field, obj, self.request), IFieldSerializer - ), + queryMultiAdapter((field, obj, self.request), IFieldSerializer), ) def _get_workflow_state(self, obj): wftool = getToolByName(self.context, "portal_workflow") - review_state = wftool.getInfoFor( - ob=obj, name="review_state", default=None - ) + review_state = wftool.getInfoFor(ob=obj, name="review_state", default=None) return review_state def check_permission(self, permission_name, obj): @@ -266,11 +248,12 @@ class SerializeFolderToJson(SerializeToJson): @classmethod def __restapi_doc_component_schema__(cls, context, request): - result = super( - cls, SerializeFolderToJson - ).__restapi_doc_component_schema__(context, request) + result = super(cls, SerializeFolderToJson).__restapi_doc_component_schema__( + context, request + ) + portal_types = getToolByName(api.portal.get(), "portal_types") - ct: dict = result[context.portal_type] + ct: dict = result[portal_types.get(context.portal_type).id] result.update({"BrainItem": {"type": "any"}}) ct.update( @@ -321,9 +304,7 @@ def __call__(self, version=None, include_items=True): )(fullobjects=True)["items"] else: result["items"] = [ - getMultiAdapter( - (brain, self.request), ISerializeToJsonSummary - )() + getMultiAdapter((brain, self.request), ISerializeToJsonSummary)() for brain in batch ] return result @@ -341,14 +322,10 @@ def __init__(self, context, request): def __call__(self): primary_field_name = self.get_primary_field_name() for schema in iterSchemata(self.context): - read_permissions = mergedTaggedValueDict( - schema, READ_PERMISSIONS_KEY - ) + read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY) for name, field in getFields(schema).items(): - if not self.check_permission( - read_permissions.get(name), self.context - ): + if not self.check_permission(read_permissions.get(name), self.context): continue if name != primary_field_name: From 593340a5232b405637856eab85926bdbee2b7373 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 17 May 2024 16:51:28 +0200 Subject: [PATCH 14/21] Fix --- src/plone/restapi/configure.zcml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/plone/restapi/configure.zcml b/src/plone/restapi/configure.zcml index 39591da576..2f79ec0c7a 100644 --- a/src/plone/restapi/configure.zcml +++ b/src/plone/restapi/configure.zcml @@ -8,15 +8,6 @@ i18n_domain="plone.restapi" > - - Date: Fri, 17 May 2024 17:12:17 +0200 Subject: [PATCH 15/21] Fix --- src/plone/restapi/configure.zcml | 9 +++++++++ src/plone/restapi/scripts/generate_json_schema.py | 2 +- src/plone/restapi/serializer/dxcontent.py | 4 ++-- src/plone/restapi/serializer/site.py | 4 +--- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/plone/restapi/configure.zcml b/src/plone/restapi/configure.zcml index 2f79ec0c7a..98cc5b4204 100644 --- a/src/plone/restapi/configure.zcml +++ b/src/plone/restapi/configure.zcml @@ -8,6 +8,15 @@ i18n_domain="plone.restapi" > + + Date: Sat, 18 May 2024 15:44:54 +0200 Subject: [PATCH 16/21] pydantic + add model for login endpoint --- setup.py | 1 + src/plone/restapi/services/auth/login.py | 34 +++++++++++------------- src/plone/restapi/services/auth/model.py | 14 ++++++++++ 3 files changed, 30 insertions(+), 19 deletions(-) create mode 100644 src/plone/restapi/services/auth/model.py diff --git a/setup.py b/setup.py index fa0db2def8..49a9a2b615 100644 --- a/setup.py +++ b/setup.py @@ -95,6 +95,7 @@ def read(filename): "PyJWT>=1.7.0", "pytz", "pyyaml", + "pyDantic", ], extras_require={"test": TEST_REQUIRES}, entry_points=""" diff --git a/src/plone/restapi/services/auth/login.py b/src/plone/restapi/services/auth/login.py index 7a792c42a7..9cddb707eb 100644 --- a/src/plone/restapi/services/auth/login.py +++ b/src/plone/restapi/services/auth/login.py @@ -7,6 +7,8 @@ from zope import component from zope.interface import alsoProvides +from .model import LoginData + import plone.protect.interfaces @@ -23,13 +25,7 @@ def __restapi_doc__(cls): "required": True, "content": { "application/json": { - "schema": { - "type": "object", - "properties": { - "login": {"type": "string", "example": "admin"}, - "password": {"type": "string", "example": "admin"}, - }, - } + "schema": LoginData.schema(), } }, }, @@ -79,28 +75,28 @@ def __restapi_doc__(cls): } def reply(self): - data = json_body(self.request) - if "login" not in data or "password" not in data: - self.request.response.setStatus(400) - return dict( - error=dict( - type="Missing credentials", - message="Login and password must be provided in body.", - ) - ) + data = LoginData(**json_body(self.request)) + # if "login" not in data or "password" not in data: + # self.request.response.setStatus(400) + # return dict( + # error=dict( + # type="Missing credentials", + # message="Login and password must be provided in body.", + # ) + # ) # Disable CSRF protection if "IDisableCSRFProtection" in dir(plone.protect.interfaces): alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection) - userid = data["login"] - password = data["password"] + userid = data.login + password = data.password uf = self._find_userfolder(userid) # Also put the password in __ac_password on the request. # The post-login code in PlonePAS expects to find it there # when it calls the PAS updateCredentials plugin. - self.request.form["__ac_password"] = data["password"] + self.request.form["__ac_password"] = password if uf is not None: plugins = uf._getOb("plugins") diff --git a/src/plone/restapi/services/auth/model.py b/src/plone/restapi/services/auth/model.py new file mode 100644 index 0000000000..23e8f31113 --- /dev/null +++ b/src/plone/restapi/services/auth/model.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class LoginData(BaseModel): + login: str + password: str + + +class TokenResponse(BaseModel): + token: str + + +class ErrorResponse(BaseModel): + error: dict From 519c6dec188a9ef56cc0d03818646b3fd9da2a74 Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 18 May 2024 15:55:22 +0200 Subject: [PATCH 17/21] Reset configure.zml to main --- src/plone/restapi/configure.zcml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/plone/restapi/configure.zcml b/src/plone/restapi/configure.zcml index 98cc5b4204..2f79ec0c7a 100644 --- a/src/plone/restapi/configure.zcml +++ b/src/plone/restapi/configure.zcml @@ -8,15 +8,6 @@ i18n_domain="plone.restapi" > - - Date: Sat, 18 May 2024 15:57:49 +0200 Subject: [PATCH 18/21] Cleanup imports --- .../restapi/scripts/generate_json_schema.py | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/src/plone/restapi/scripts/generate_json_schema.py b/src/plone/restapi/scripts/generate_json_schema.py index 6e9a312951..996bee492b 100644 --- a/src/plone/restapi/scripts/generate_json_schema.py +++ b/src/plone/restapi/scripts/generate_json_schema.py @@ -6,37 +6,9 @@ from Products.CMFCore.utils import getToolByName from zope.publisher.interfaces.browser import IBrowserRequest from plone import api -from zope.schema import getFields -from collections import OrderedDict -from copy import copy -from plone.autoform.form import AutoExtensibleForm -from plone.autoform.interfaces import IParameterizedWidget -from plone.autoform.interfaces import WIDGETS_KEY -from plone.behavior.interfaces import IBehavior -from plone.dexterity.interfaces import IDexterityContent -from plone.dexterity.interfaces import IDexterityFTI -from plone.dexterity.utils import getAdditionalSchemata -from plone.i18n.normalizer import idnormalizer -from plone.restapi.interfaces import IFieldDeserializer -from plone.restapi.serializer.converters import IJsonCompatible -from plone.restapi.types.interfaces import IJsonSchemaProvider -from plone.supermodel import serializeModel -from plone.supermodel.interfaces import FIELDSETS_KEY -from plone.supermodel.utils import mergedTaggedValueDict -from plone.supermodel.utils import syncSchema from Products.CMFCore.utils import getToolByName -from z3c.form import form as z3c_form -from zExceptions import BadRequest from zope.component import getMultiAdapter -from zope.component import queryMultiAdapter -from zope.component import queryUtility -from zope.component.hooks import getSite from zope.globalrequest import getRequest -from zope.i18n import translate -from zope.interface import implementer -from zope.schema.interfaces import IVocabularyFactory -from plone.restapi.types.utils import get_fieldsets, get_jsonschema_properties -from Products.CMFPlone.interfaces import IPloneSiteRoot from plone.restapi.interfaces import ISerializeToJson logger = logging.getLogger(__name__) From 6ff5cb86c7ea3b0787de5006fcaa7d154184af42 Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 18 May 2024 15:59:52 +0200 Subject: [PATCH 19/21] Formatting --- src/plone/restapi/serializer/dxcontent.py | 1 - src/plone/restapi/serializer/site.py | 1 - src/plone/restapi/services/content/get.py | 17 ++++------------- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py index a9f3b191c9..9861da253e 100644 --- a/src/plone/restapi/serializer/dxcontent.py +++ b/src/plone/restapi/serializer/dxcontent.py @@ -245,7 +245,6 @@ def check_permission(self, permission_name, obj): @implementer(ISerializeToJson) @adapter(IDexterityContainer, Interface) class SerializeFolderToJson(SerializeToJson): - @classmethod def __restapi_doc_component_schema__(cls, context, request): result = super(cls, SerializeFolderToJson).__restapi_doc_component_schema__( diff --git a/src/plone/restapi/serializer/site.py b/src/plone/restapi/serializer/site.py index 6a234ea2a9..d3345fc584 100644 --- a/src/plone/restapi/serializer/site.py +++ b/src/plone/restapi/serializer/site.py @@ -36,7 +36,6 @@ @implementer(ISerializeToJson) @adapter(IPloneSiteRoot, Interface) class SerializeSiteRootToJson: - @classmethod def __restapi_doc_component_schema__(cls, context, request): fields_adapter = [] diff --git a/src/plone/restapi/services/content/get.py b/src/plone/restapi/services/content/get.py index cf6db1beaf..edea4fee0a 100644 --- a/src/plone/restapi/services/content/get.py +++ b/src/plone/restapi/services/content/get.py @@ -14,9 +14,7 @@ class ContentGet(Service): @classmethod def __restapi_doc_component_schemas_extension__(cls): - doc = super( - ContentGet, cls - ).__restapi_doc_component_schemas_extension__() + doc = super(ContentGet, cls).__restapi_doc_component_schemas_extension__() # recupero le interfacce per cui il service è stato registrato services = [ @@ -46,15 +44,12 @@ def __restapi_doc_component_schemas_extension__(cls): # di contenuto definition = {} for fti in ftis: - serializer = queryMultiAdapter( - (fti[1], getRequest()), ISerializeToJson - ) + 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 { @@ -77,9 +72,7 @@ def __restapi_doc__(cls): "description": "ServerError", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } + "schema": {"$ref": "#/components/schemas/ErrorResponse"} } }, }, @@ -88,9 +81,7 @@ def __restapi_doc__(cls): } def reply(self): - serializer = queryMultiAdapter( - (self.context, self.request), ISerializeToJson - ) + serializer = queryMultiAdapter((self.context, self.request), ISerializeToJson) if serializer is None: self.request.response.setStatus(501) From 447f724d524e03cf425921d80ced011145eaa674 Mon Sep 17 00:00:00 2001 From: Filippo Campi Date: Mon, 20 May 2024 12:07:09 +0200 Subject: [PATCH 20/21] create an endpoint swagger to return openapi specification + various fix --- src/plone/restapi/configure.zcml | 9 + src/plone/restapi/serializer/dxcontent.py | 54 +++--- src/plone/restapi/serializer/dxfields.py | 4 +- src/plone/restapi/serializer/site.py | 16 +- src/plone/restapi/services/__init__.py | 22 +-- .../restapi/services/auth/configure.zcml | 1 - src/plone/restapi/services/auth/login.py | 70 ++++--- src/plone/restapi/services/auth/model.py | 8 +- src/plone/restapi/services/configure.zcml | 1 + src/plone/restapi/services/content/get.py | 1 - src/plone/restapi/services/model.py | 12 ++ .../restapi/services/swagger/__init__.py | 0 .../restapi/services/swagger/configure.zcml | 14 ++ src/plone/restapi/services/swagger/get.py | 177 ++++++++++++++++++ 14 files changed, 296 insertions(+), 93 deletions(-) create mode 100644 src/plone/restapi/services/model.py create mode 100644 src/plone/restapi/services/swagger/__init__.py create mode 100644 src/plone/restapi/services/swagger/configure.zcml create mode 100644 src/plone/restapi/services/swagger/get.py 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 edea4fee0a..ae9dde331c 100644 --- a/src/plone/restapi/services/content/get.py +++ b/src/plone/restapi/services/content/get.py @@ -62,7 +62,6 @@ def __restapi_doc__(cls): "content": { "application/json": { "schema": { - "type": "object", "$ref": "$ContextType", } } 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 From 70a57a57466837a9840e83cec6fdee3e39622dd4 Mon Sep 17 00:00:00 2001 From: Filippo Campi Date: Mon, 20 May 2024 12:07:52 +0200 Subject: [PATCH 21/21] removed script --- .../restapi/scripts/generate_json_schema.py | 215 ------------------ 1 file changed, 215 deletions(-) delete mode 100644 src/plone/restapi/scripts/generate_json_schema.py diff --git a/src/plone/restapi/scripts/generate_json_schema.py b/src/plone/restapi/scripts/generate_json_schema.py deleted file mode 100644 index 996bee492b..0000000000 --- a/src/plone/restapi/scripts/generate_json_schema.py +++ /dev/null @@ -1,215 +0,0 @@ -# -*- coding: utf-8 -*- -import logging -import importlib -import yaml -from plone.app.customerize import registration -from Products.CMFCore.utils import getToolByName -from zope.publisher.interfaces.browser import IBrowserRequest -from plone import api -from Products.CMFCore.utils import getToolByName -from zope.component import getMultiAdapter -from zope.globalrequest import getRequest -from plone.restapi.interfaces import ISerializeToJson - -logger = logging.getLogger(__name__) - - -class Application(object): - """ """ - - @classmethod - def run(cls): - 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", - }, - "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": { - # TODO andrà sovrascritto - "ContentType": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "Title", - }, - }, - } - }, - } - }, - }, - "paths": {}, - "security": [{"bearerAuth": []}], - } - - for ct, services in cls.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 = cls.get_doc_by_service(service) - - if not service_doc: - logger.warning( - f"No documentation found for /{ct}/{'@' + service.name.split('@')[-1]}" - ) - 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 = cls.get_doc_schemas_by_service(service) - - if component: - openapi_doc_boilerplate["components"]["schemas"].update(component) - - cls.inject_schemas( - doc, - schemas={"$ContextType": f"#/components/schemas/{ct}"}, - ) - - with open("openapi_doc.yaml", "w") as docfile: - docfile.write(cls.generate_yaml_by_doc(openapi_doc_boilerplate)) - - @classmethod - def inject_schemas(cls, 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) - - @classmethod - def generate_yaml_by_doc(cls, doc): - return yaml.safe_dump(doc) - - @classmethod - def get_services_by_ct(cls): - 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 - - @classmethod - def get_doc_by_service(cls, service): - # Supposed to be extended later - doc = getattr(service.factory, "__restapi_doc__", None) - if callable(doc): - return doc() - - return None - - @classmethod - def get_doc_schemas_by_service(cls, service): - doc = getattr( - service.factory, "__restapi_doc_component_schemas_extension__", None - ) - - if callable(doc): - return doc() - - return None - - @classmethod - def get_ct_schemas(cls): - - portal_types = getToolByName(api.portal.get(), "portal_types") - - for fti in portal_types.listTypeInfo(): - klass = getattr(fti, "klass", None) - - if klass: - klass = getattr( - importlib.import_module(".".join(klass.split(".")[:-1])), - klass.split(".")[-1], - ) - - if isinstance(api.portal.get(), klass): - obj = api.portal.get() - else: - obj = klass() - - # Doc retrieve here - yield getMultiAdapter((obj, getRequest()), ISerializeToJson) - - else: - logger.warning(f"Could not find a schema for {fti.id}") - - -if __name__ == "__main__": - Application.run()