Skip to content

Commit

Permalink
Add support to Pydantic (#566)
Browse files Browse the repository at this point in the history
Add experimental support to serialize Pydantic models and Python dataclasses directly on views (input and output).

* Add Pydantic dependency: https://docs.pydantic.dev/2.9/
* Remove dacite dependency: https://github.com/konradhalas/dacite
* Add Pyright to static type checker, https://microsoft.github.io/pyright/

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
  • Loading branch information
nycholas and dependabot[bot] authored Sep 26, 2024
1 parent 04826ca commit 62cbfe7
Show file tree
Hide file tree
Showing 39 changed files with 3,234 additions and 1,049 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/on_update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
- name: Run tox
if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.12' }}
run: |
tox -e py,py-async,style,typing-mypy,security-safety,security-bandit,docs -p all
tox -e py,py-async,style,typing-mypy,typing-pyright,security-safety,security-bandit,docs -p all
- name: Run tox (Pytype)
if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.11' }}
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pre_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: Run tox
if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.12' }}
run: |
tox -e py,py-async,style,typing-mypy,security-safety,security-bandit,docs -p all
tox -e py,py-async,style,typing-mypy,typing-pyright,security-safety,security-bandit,docs -p all
- name: Run tox (Pytype)
if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.11' }}
run: |
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ clean:
@find . -name ".coverage" | xargs rm -rf
@rm -rf .coverage coverage.* .eggs/ .mypy_cache/ .pytype/ .ruff_cache/ .pytest_cache/ .tox/ src/Flask_JSONRPC.egg-info/ htmlcov/ junit/ htmldoc/ build/ dist/ wheelhouse/

style:
@ruff check .
@ruff format .

test: clean
@python -m pip install --upgrade tox
@python -m tox
Expand Down
6 changes: 3 additions & 3 deletions examples/openrpc/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Testing your service
2. Testing

::
$ curl 'http://localhost:5000/api' -X POST \
$ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \
--data-raw '{
"jsonrpc": "2.0",
"method": "Petstore.create_pet",
Expand All @@ -40,7 +40,7 @@ Testing your service


::
$ curl 'http://localhost:5000/api' -X POST \
$ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \
--data-raw '{
"jsonrpc": "2.0",
"method": "Petstore.get_pets",
Expand Down Expand Up @@ -77,7 +77,7 @@ Testing your service


::
$ curl 'http://localhost:5000/api' -X POST \
$ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \
--data-raw '{
"jsonrpc": "2.0",
"method": "Petstore.get_pet_by_id",
Expand Down
117 changes: 117 additions & 0 deletions examples/pydantic/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
petstore
=======

A petstore application.


Testing your service
********************

1. Running

::

$ python petstore.py
* Running on http://0.0.0.0:5000/

2. Testing

::
$ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \
--data-raw '{
"jsonrpc": "2.0",
"method": "Petstore.create_pet",
"params": {
"pet": {
"name": "Jhon",
"tag": "cat"
}
},
"id": "1c7fb3b2-7a87-4cf7-8e28-aafc33dae71d"
}'
{
"id": "1c7fb3b2-7a87-4cf7-8e28-aafc33dae71d",
"jsonrpc": "2.0",P
"result": {
"id": 32,
"name": "Jhon",
"tag": "cat"
}
}



::
$ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \
--data-raw '{
"jsonrpc": "2.0",
"method": "Petstore.get_pets",
"params": {},
"id": "16ebeed1-748c-4983-ba19-2848692c873a"
}'
{
"id": "16ebeed1-748c-4983-ba19-2848692c873a",
"jsonrpc": "2.0",
"result": [
{
"id": 1,
"name": "Bob",
"tag": "dog"
},
{
"id": 2,
"name": "Eve",
"tag": "cat"
},
{
"id": 3,
"name": "Alice",
"tag": "bird"
},
{
"id": 32,
"name": "Jhon",
"tag": "cat"
}
]
}



::
$ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \
--data-raw '{
"jsonrpc": "2.0",
"method": "Petstore.get_pet_by_id",
"params": {
"id": 32
},
"id": "5dfbd1c0-6919-4ce2-a05e-0b4a4aa2aeb2"
}'
{
"id": "5dfbd1c0-6919-4ce2-a05e-0b4a4aa2aeb2",
"jsonrpc": "2.0",
"result": {
"id": 32,
"name": "Jhon",
"tag": "cat"
}
}



::
$ curl 'http://localhost:5000/api' -X POST -H 'Content-Type: application/json;charset=utf-8' \
--data-raw '{
"jsonrpc": "2.0",
"method": "Petstore.delete_pet_by_id",
"params": {
"id": 32
},
"id": "706cf9c3-5b5d-4288-8555-a67c8b5de481"
}'
{
"id": "706cf9c3-5b5d-4288-8555-a67c8b5de481",
"jsonrpc": "2.0",
"result": null
}
203 changes: 203 additions & 0 deletions examples/pydantic/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env python
# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of the Cenobit Technologies nor the names of
# its contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# isort:skip_file
import os
import sys
import typing as t
import random

from flask import Flask

try:
from flask_jsonrpc import JSONRPC
except ModuleNotFoundError:
project_dir, project_module_name = os.path.split(os.path.dirname(os.path.realpath(__file__)))
flask_jsonrpc_project_dir = os.path.join(project_dir, os.pardir, 'src')
if os.path.exists(flask_jsonrpc_project_dir) and flask_jsonrpc_project_dir not in sys.path:
sys.path.append(flask_jsonrpc_project_dir)

from flask_jsonrpc import JSONRPC

from pydantic import BaseModel

from flask_jsonrpc.contrib.openrpc import OpenRPC
from flask_jsonrpc.contrib.openrpc import typing as st

app = Flask('openrpc')
jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True)
openrpc = OpenRPC(
app,
jsonrpc,
openrpc_schema=st.OpenRPCSchema(
openrpc='1.0.0-rc1',
info=st.Info(
version='1.0.0',
title='Petstore Expanded',
description=(
'A sample API that uses a petstore as an example to '
'demonstrate features in the OpenRPC specification'
),
terms_of_service='https://open-rpc.org',
contact=st.Contact(name='OpenRPC Team', email='[email protected]', url='https://open-rpc.org'),
license=st.License(name='Apache 2.0', url='https://www.apache.org/licenses/LICENSE-2.0.html'),
),
servers=[st.Server(url='http://petstore.open-rpc.org')],
components=st.Components(
schemas={
'Pet': st.Schema(
all_of=[
st.Schema(ref='#/components/schemas/NewPet'),
st.Schema(required=['id'], properties={'id': st.Schema(type=st.SchemaDataType.INTEGER)}),
]
),
'NewPet': st.Schema(
type=st.SchemaDataType.OBJECT,
required=['name'],
properties={
'name': st.Schema(type=st.SchemaDataType.STRING),
'tag': st.Schema(type=st.SchemaDataType.STRING),
},
),
}
),
external_docs=st.ExternalDocumentation(
url='https://github.com/open-rpc/examples/blob/master/service-descriptions/petstore-expanded-openrpc.json'
),
),
)


class NewPet(BaseModel):
name: str
tag: str


class Pet(NewPet):
id: int


PETS = [Pet(id=1, name='Bob', tag='dog'), Pet(id=2, name='Eve', tag='cat'), Pet(id=3, name='Alice', tag='bird')]


@openrpc.extend_schema(
name='Petstore.get_pets',
description=(
'Returns all pets from the system that the user has access to\n'
'Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem '
'sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio '
'lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar '
'ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer '
'at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie '
'imperdiet. Vivamus id aliquam diam.'
),
params=[
st.ContentDescriptor(
name='tags',
description='tags to filter by',
schema=st.Schema(type=st.SchemaDataType.ARRAY, items=st.Schema(type=st.SchemaDataType.STRING)),
),
st.ContentDescriptor(
name='limit',
description='maximum number of results to return',
schema=st.Schema(type=st.SchemaDataType.INTEGER),
),
],
result=st.ContentDescriptor(
name='pet',
description='pet response',
schema=st.Schema(type=st.SchemaDataType.ARRAY, items=st.Schema(ref='#/components/schemas/Pet')),
),
)
@jsonrpc.method('Petstore.get_pets')
def get_pets(tags: t.Optional[t.List[str]] = None, limit: t.Optional[int] = None) -> t.List[Pet]:
pets = PETS
if tags is not None:
pets = [pet for pet in pets if pet.tag in tags]
if limit is not None:
pets = pets[:limit]
return pets


@openrpc.extend_schema(
name='Petstore.create_pet',
description='Creates a new pet in the store. Duplicates are allowed',
params=[
st.ContentDescriptor(
name='newPet', description='Pet to add to the store.', schema=st.Schema(ref='#/components/schemas/NewPet')
)
],
result=st.ContentDescriptor(
name='pet', description='the newly created pet', schema=st.Schema(ref='#/components/schemas/Pet')
),
)
@jsonrpc.method('Petstore.create_pet')
def create_pet(pet: NewPet) -> Pet:
pet = Pet(id=random.randint(4, 100), name=pet.name, tag=pet.tag)
PETS.append(pet)
return pet


@openrpc.extend_schema(
name='Petstore.get_pet_by_id',
description='Returns a user based on a single ID, if the user does not have access to the pet',
params=[
st.ContentDescriptor(
name='id', description='ID of pet to fetch', required=True, schema=st.Schema(type=st.SchemaDataType.INTEGER)
)
],
result=st.ContentDescriptor(
name='pet', description='pet response', schema=st.Schema(ref='#/components/schemas/Pet')
),
)
@jsonrpc.method('Petstore.get_pet_by_id')
def get_pet_by_id(id: int) -> t.Optional[Pet]:
pet = [pet for pet in PETS if pet.id == id]
return None if len(pet) == 0 else pet[0]


@openrpc.extend_schema(
name='Petstore.delete_pet_by_id',
description='deletes a single pet based on the ID supplied',
params=[
st.ContentDescriptor(
name='id',
description='ID of pet to delete',
required=True,
schema=st.Schema(type=st.SchemaDataType.INTEGER),
)
],
result=st.ContentDescriptor(name='pet', description='pet deleted', schema=st.Schema()),
)
@jsonrpc.method('Petstore.delete_pet_by_id')
def delete_pet_by_id(id: int) -> None:
global PETS
PETS = [pet for pet in PETS if pet.id != id] # noqa: F823, F841


if __name__ == '__main__':
app.run(host='0.0.0.0', debug=True)
Loading

0 comments on commit 62cbfe7

Please sign in to comment.