sanic-org / sanic-ext

Extended Sanic functionality
https://sanic.dev/en/plugins/sanic-ext/getting-started.html
MIT License
50 stars 35 forks source link

[Bug] 22.9.0 - TypeError: <class 'str'> is not JSON serializable for OpenAPI blueprint #136

Closed morph027 closed 1 year ago

morph027 commented 2 years ago

Describe the bug

I've upgraded my project to 22.9.0 and when trying to open swagger UI, it fails with the following stacktrace:

[2022-09-29 17:12:42 +0000] [7] [INFO] 
  ┌─────────────────────────────────────────────────────────────────────────────────────┐
  │                                    Sanic v22.9.0                                    │
  │                           Goin' Fast @ http://0.0.0.0:8080                          │
  ├───────────────────────┬─────────────────────────────────────────────────────────────┤
  │                       │     mode: production, single worker                         │
  │     ▄███ █████ ██     │   server: sanic, HTTP/1.1                                   │
  │    ██                 │   python: 3.8.10                                            │
  │     ▀███████ ███▄     │ platform: Linux-5.19.5-051905-generic-x86_64-with-glibc2.29 │
  │                 ██    │ packages: sanic-routing==22.8.0, sanic-ext==22.9.0          │
  │    ████ ████████▀     │                                                             │
  │                       │                                                             │
  │ Build Fast. Run Fast. │                                                             │
  └───────────────────────┴─────────────────────────────────────────────────────────────┘

[2022-09-29 17:12:42 +0000] [7] [WARNING] Sanic is running in PRODUCTION mode. Consider using '--debug' or '--dev' while actively developing your application.
[2022-09-29 17:12:42 +0000] [17] [INFO] Sanic Extensions:
[2022-09-29 17:12:42 +0000] [17] [INFO]   > injection [0 added]
[2022-09-29 17:12:42 +0000] [17] [INFO]   > openapi [http://0.0.0.0:8080/docs]
[2022-09-29 17:12:42 +0000] [17] [INFO]   > http 
[2022-09-29 17:12:42 +0000] [17] [INFO] Starting worker [17]
[2022-09-29 17:12:45 +0000] [17] [ERROR] Exception occurred while handling uri: 'http://localhost:8080/docs/openapi.json'
Traceback (most recent call last):
  File "handle_request", line 94, in handle_request
  File "/var/lib/python-signal-cli-rest-api/.venv/lib/python3.8/site-packages/sanic_ext/extensions/openapi/blueprint.py", line 52, in spec
    return json(SpecificationBuilder().build(request.app).serialize())
  File "/var/lib/python-signal-cli-rest-api/.venv/lib/python3.8/site-packages/sanic/response.py", line 244, in json
    dumps(body, **kwargs),
TypeError: <class 'str'> is not JSON serializable

To Reproduce

Not sure if this happens on my project only. Will try to re-create with a blank dummy project.

Expected behavior

Render openapi.json

morph027 commented 2 years ago

This is the data it tries to serialize in https://github.com/sanic-org/sanic-ext/blob/v22.9.0/sanic_ext/extensions/openapi/types.py#L57

{'openapi': '3.0.3', 'info': {'title': 'Signal Cli REST API', 'version': '22.8.1', 'description': 'This is the Signal Cli REST API documentation.', 'license': {'name': 'MIT', 'url': 'https://mit-license.org/'}, 'contact': {}}, 'paths': {'/v1/about': {'get': {'operationId': 'get~about_v1.about_v1_get', 'summary': 'Lists general information about the API.', 'tags': ['General'], 'responses': {200: {'content': {'application/json': {'schema': {'mode': <class 'str'>, 'versions': typing.List[str]}}}, 'description': 'OK'}}, 'description': 'Returns the supported API versions.'}}, '/v1/search': {'get': {'operationId': 'get~search_v1.search_v1_get', 'summary': 'Check if one or more phone numbers are registered with the Signal Service.', 'tags': ['Search'], 'parameters': [{'name': 'numbers', 'schema': {'type': 'array', 'items': {'type': 'string'}}, 'description': 'Numbers to check', 'required': True, 'in': 'query'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 200: {'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'object', 'properties': {'number': {'type': 'string'}, 'registered': {'type': 'boolean'}}}}}}, 'description': 'OK'}}, 'description': 'Check if one or more phone numbers are registered with the Signal Service.'}}, '/v2/send': {'post': {'operationId': 'post~send_v2.send_v2_post', 'summary': 'Send a signal message.', 'tags': ['Messages'], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 201: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'timestamp': {'type': 'string'}}}}}, 'description': 'Created'}}, 'description': 'Send a signal message.`number` can be ommited if API is running w/ `PYTHON_SIGNAL_CLI_REST_API_ACCOUNT`', 'requestBody': {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'recipients': {'type': 'array', 'items': {'type': 'string'}}, 'message': {'type': 'string'}, 'number': {'type': 'string', 'nullable': True}, 'base64_attachments': {'type': 'array', 'items': {'type': 'string'}, 'nullable': True}, 'mentions': {'type': 'array', 'items': {'type': 'string'}, 'nullable': True}}}}}, 'required': True}}}, '/openapi/assets/swagger/{__file_uri__}': {'get': {'operationId': 'get~swagger-assets', 'summary': 'Swagger-Assets', 'parameters': [{'name': '__file_uri__', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {'default': {'description': 'OK'}}}}, '/v1/groups/{number}': {'get': {'operationId': 'get~groups_of_number_v1.groups_for_number_get', 'summary': 'List all Signal Groups.', 'tags': ['Groups'], 'parameters': [{'name': 'number', 'schema': {'type': 'string'}, 'description': 'Registered Phone Number', 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 200: {'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'object', 'properties': {'blocked': {'type': 'boolean'}, 'id': {'type': 'string'}, 'invite_link': {'type': 'string'}, 'members': {'type': 'array', 'items': {'type': 'string'}}, 'name': {'type': 'string'}, 'pending_invites': {'type': 'array', 'items': {'type': 'string'}}, 'pending_requests': {'type': 'array', 'items': {'type': 'string'}}, 'message_expiration_timer': {'type': 'integer', 'format': 'int32'}, 'admins': {'type': 'array', 'items': {'type': 'string'}}, 'description': {'type': 'string'}}}}}}, 'description': 'OK'}}, 'description': 'List all Signal Groups.'}, 'post': {'operationId': 'post~create_group_v1.create_group_v1_post', 'summary': 'Create a new Signal Group with the specified members.', 'tags': ['Groups'], 'parameters': [{'name': 'number', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 201: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'group_id': {'type': 'string'}}}}}, 'description': 'Created'}}, 'description': 'Create a new Signal Group.', 'requestBody': {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'name': {'type': 'string'}, 'members': {'type': 'array', 'items': {'type': 'string'}}, 'permissions': {'type': 'object', 'nullable': True, 'properties': {'add_members': {'type': 'string', 'default': 'only-admins'}, 'edit_group': {'type': 'string', 'default': 'only-admins'}}}, 'group_link': {'type': 'string', 'enum': ['disabled', 'enabled', 'enabled-with-approval']}, 'admins': {'type': 'array', 'items': {'type': 'string'}, 'nullable': True}, 'description': {'type': 'string', 'nullable': True}, 'base64_avatar': {'type': 'string', 'nullable': True}, 'message_expiration_timer': {'type': 'integer', 'format': 'int32', 'nullable': True}}}}}, 'required': True}}}, '/v1/groups/{number}/{groupid}': {'patch': {'operationId': 'patch~update_group_v1.update_group_v1_patch', 'summary': 'Update an existing Signal Group.', 'tags': ['Groups'], 'parameters': [{'name': 'groupid', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}, {'name': 'number', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 200: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'blocked': {'type': 'boolean'}, 'id': {'type': 'string'}, 'invite_link': {'type': 'string'}, 'members': {'type': 'array', 'items': {'type': 'string'}}, 'name': {'type': 'string'}, 'pending_invites': {'type': 'array', 'items': {'type': 'string'}}, 'pending_requests': {'type': 'array', 'items': {'type': 'string'}}, 'message_expiration_timer': {'type': 'integer', 'format': 'int32'}, 'admins': {'type': 'array', 'items': {'type': 'string'}}, 'description': {'type': 'string'}}}}}, 'description': 'OK'}}, 'description': 'Update an existing Signal Group.', 'requestBody': {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'group_link': {'type': 'string', 'enum': ['unchanged', 'disabled', 'enabled', 'enabled-with-approval']}, 'add_admins': {'type': 'array', 'items': {'type': 'string'}, 'nullable': True}, 'add_members': {'type': 'array', 'items': {'type': 'string'}, 'nullable': True}}}}}}}, 'get': {'operationId': 'get~group_details_v1.groups_of_number_get', 'summary': 'List a Signal Group.', 'tags': ['Groups'], 'parameters': [{'name': 'groupid', 'schema': {'type': 'string'}, 'description': 'Group ID', 'required': True, 'in': 'path'}, {'name': 'number', 'schema': {'type': 'string'}, 'description': 'Registered Phone Number', 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 200: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'blocked': {'type': 'boolean'}, 'id': {'type': 'string'}, 'invite_link': {'type': 'string'}, 'members': {'type': 'array', 'items': {'type': 'string'}}, 'name': {'type': 'string'}, 'pending_invites': {'type': 'array', 'items': {'type': 'string'}}, 'pending_requests': {'type': 'array', 'items': {'type': 'string'}}, 'message_expiration_timer': {'type': 'integer', 'format': 'int32'}, 'admins': {'type': 'array', 'items': {'type': 'string'}}, 'description': {'type': 'string'}}}}}, 'description': 'OK'}}, 'description': 'List a Signal Group.'}, 'delete': {'operationId': 'delete~delete_group_v1.delete_group_v1_delete', 'summary': 'Delete the specified Signal Group.', 'tags': ['Groups'], 'parameters': [{'name': 'groupid', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}, {'name': 'number', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 204: {'content': {'*/*': {'schema': {}}}, 'description': 'Deleted'}}, 'description': 'Delete a Signal Group.'}}, '/v1/groups/{number}/{groupid}/join': {'post': {'operationId': 'post~join_group_v1.join_group_v1_post', 'summary': 'Join a Signal Group.', 'tags': ['Groups'], 'parameters': [{'name': 'groupid', 'schema': {'type': 'string'}, 'description': 'Group invite link like https://signal.group/#...', 'required': True, 'in': 'path'}, {'name': 'number', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 204: {'content': {'*/*': {'schema': {'type': 'string'}}}, 'description': 'OK'}}, 'description': 'Join a Signal Group.'}}, '/v1/groups/{number}/{groupid}/quit': {'post': {'operationId': 'post~quit_group_v1.quit_group_v1_post', 'summary': 'Quit (leave) a Signal Group.', 'tags': ['Groups'], 'parameters': [{'name': 'groupid', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}, {'name': 'number', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 204: {'content': {'*/*': {'schema': {'type': 'string'}}}, 'description': 'OK'}}, 'description': 'Quit (leave) a Signal Group.'}}, '/v1/reactions/{number}': {'post': {'operationId': 'post~reactions_v1.reactions_v1_post', 'summary': 'Send a reaction.', 'tags': ['Reactions'], 'parameters': [{'name': 'number', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 201: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'timestamp': {'type': 'string'}}}}}, 'description': 'Created'}}, 'description': 'Send a reaction.', 'requestBody': {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'reaction': {'type': 'string'}, 'target_author': {'type': 'string'}, 'timestamp': {'type': 'integer', 'format': 'int32'}, 'recipient': {'type': 'string'}, 'groupid': {'type': 'string', 'nullable': True}, 'remove': {'type': 'boolean', 'nullable': True}}}}}, 'required': True}}, 'delete': {'operationId': 'delete~reactions_v1.reactions_v1_delete', 'summary': 'Delete a reaction.', 'tags': ['Reactions'], 'parameters': [{'name': 'number', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 200: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'timestamp': {'type': 'string'}}}}}, 'description': 'Deleted'}}, 'description': 'Delete a reaction.', 'requestBody': {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'recipient': {'type': 'string'}, 'target_author': {'type': 'string'}, 'timestamp': {'type': 'integer', 'format': 'int32'}}}}}, 'required': True}}}, '/v1/register/{number}': {'post': {'operationId': 'post~register_v1.register_post', 'summary': 'Register a phone number.', 'tags': ['Devices'], 'parameters': [{'name': 'number', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 201: {'content': {'application/json': {'schema': {'type': 'object'}}}, 'description': 'Created'}}, 'description': 'Register a phone number with the signal network.', 'requestBody': {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'captcha': {'type': 'string', 'nullable': True}, 'use_voice': {'type': 'boolean', 'nullable': True}}}}}}}}, '/v1/send/{recipient}': {'post': {'operationId': 'post~send_v1.send_v1_post', 'summary': 'Send a signal message.', 'tags': ['Messages'], 'parameters': [{'name': 'recipient', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 201: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'timestamp': {'type': 'string'}}}}}, 'description': 'Created'}}, 'description': 'Send a signal message.`number` can be ommited if API is running w/ `PYTHON_SIGNAL_CLI_REST_API_`', 'requestBody': {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'message': {'type': 'string'}, 'number': {'type': 'string', 'nullable': True}, 'base64_attachments': {'type': 'array', 'items': {'type': 'string'}}}}}}, 'required': True}}}, '/v1/register/{number}/verify/{token}': {'post': {'operationId': 'post~verify_v1.verify_post', 'summary': 'Verify a registered phone number.', 'tags': ['Devices'], 'parameters': [{'name': 'token', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}, {'name': 'number', 'schema': {'type': 'string'}, 'required': True, 'in': 'path'}], 'responses': {400: {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'error': {'type': 'string'}}}}}, 'description': 'Bad Request'}, 201: {'content': {'application/json': {'schema': {'type': 'object'}}}, 'description': 'Created'}}, 'description': 'Verify a registered phone number with the signal network.', 'requestBody': {'content': {'application/json': {'schema': {'type': 'object', 'properties': {'pin': {'type': 'string', 'nullable': True}}}}}}}}}, 'tags': [{'name': 'General'}, {'name': 'Search'}, {'name': 'Messages'}, {'name': 'Groups'}, {'name': 'Reactions'}, {'name': 'Devices'}], 'servers': [], 'security': []}
ahopkins commented 2 years ago

LMK if you can make a small repro example :sunglasses:

morph027 commented 2 years ago

Fails

from dataclasses import dataclass, field
from typing import Optional, List
from sanic import Sanic
from sanic.response import json
from sanic_ext import openapi, validate

app = Sanic("Test")

@dataclass
class Body:
    param_a: str
    param_b: int
    param_c: Optional[list[str]] = field(default_factory=list)

@app.post("/")
@openapi.body({"application/json": Body})
@openapi.response(
    200,
    {
        "application/json": {
            "mode": str,
            "versions": List[str],
        }
    },
    description="OK",
)
@validate(Body)
async def test(request, body: Body):
    return json({"message": "Hello world!"})

Works:

from dataclasses import dataclass, field
from typing import Optional, List
from sanic import Sanic
from sanic.response import json
from sanic_ext import openapi, validate

app = Sanic("Test")

@dataclass
class Body:
    param_a: str
    param_b: int
    param_c: Optional[list[str]] = field(default_factory=list)

@dataclass
class Response:
    mode: str
    versions: List[str]

@app.post("/")
@openapi.body({"application/json": Body})
@openapi.response(
    200,
    {
        "application/json": Response
    },
    description="OK",
)
@validate(Body)
async def test(request, body: Body):
    return json({"message": "Hello world!"})

So specifying a dataclass as response is fine (which is okay for me, just need to migrate one single endpoint).

ahopkins commented 2 years ago

Ahh.... yeah, that makes sense. The PR I added for the dict in the boy does not apply to response. I'll add that soon and release it as a patch. It was an oversight.