Skip to content

Commit 95d3179

Browse files
Merge pull request #28 from AdCombo/develop
for release 1.0.1
2 parents 23a3ecf + 94d7ca5 commit 95d3179

File tree

9 files changed

+265
-5
lines changed

9 files changed

+265
-5
lines changed

.github/workflows/publish_to_pypi.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Publish Python distributions to PyPI
2+
3+
on:
4+
release:
5+
types: [created]
6+
7+
jobs:
8+
build-n-publish:
9+
name: Build and publish Python distributions to PyPI
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v2
13+
- name: Set up Python 3.8
14+
uses: actions/setup-python@v1
15+
with:
16+
python-version: '3.8'
17+
- name: Install dependencies
18+
run: |
19+
python -m pip install --upgrade pip
20+
pip install setuptools wheel twine
21+
- name: Build and publish
22+
env:
23+
TWINE_USERNAME: __token__
24+
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
25+
run: |
26+
python setup.py sdist bdist_wheel
27+
twine upload dist/*

flask_combo_jsonapi/errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def format_http_exception(ex):
3030
code = getattr(ex, 'code', None)
3131
try:
3232
status = int(code)
33-
except TypeError:
33+
except (TypeError, ValueError):
3434
return
3535

3636
api_ex = STATUS_MAP.get(status)

flask_combo_jsonapi/resource.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from flask_combo_jsonapi.schema import compute_schema, get_relationships, get_model_field
1818
from flask_combo_jsonapi.data_layers.base import BaseDataLayer
1919
from flask_combo_jsonapi.data_layers.alchemy import SqlalchemyDataLayer
20-
from flask_combo_jsonapi.utils import JSONEncoder
20+
from flask_combo_jsonapi.utils import JSONEncoder, validate_model_init_params
2121

2222

2323
class ResourceMeta(MethodViewType):
@@ -39,6 +39,14 @@ def __new__(cls, name, bases, d):
3939
data_layer_kwargs = d["data_layer"]
4040
rv._data_layer = data_layer_cls(data_layer_kwargs)
4141

42+
if "schema" in d and "model" in data_layer_kwargs:
43+
model = data_layer_kwargs["model"]
44+
schema_fields = [get_model_field(d["schema"], key) for key in d["schema"]._declared_fields.keys()]
45+
invalid_params = validate_model_init_params(model=model, params_names=schema_fields)
46+
if invalid_params:
47+
raise Exception(f"Construction of {name} failed. Schema '{d['schema'].__name__}' has "
48+
f"fields={invalid_params} are not declare in {model.__name__} init parameters")
49+
4250
rv.decorators = (check_headers,)
4351
if "decorators" in d:
4452
rv.decorators += d["decorators"]

flask_combo_jsonapi/schema.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ def get_nested_fields(schema, model_field=False):
108108

109109
nested_fields = []
110110
for (key, value) in schema._declared_fields.items():
111-
if isinstance(value, List) and isinstance(value.container, Nested) \
112-
and not isinstance(value.container, Relationship):
111+
if isinstance(value, List) and isinstance(value.inner, Nested) \
112+
and not isinstance(value.inner, Relationship):
113113
nested_fields.append(key)
114114
elif isinstance(value, Nested) and not isinstance(value, Relationship):
115115
nested_fields.append(key)

flask_combo_jsonapi/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import inspect
2+
13
import simplejson as json
24
from uuid import UUID
35
from datetime import datetime
@@ -17,3 +19,30 @@ def default(self, obj):
1719
elif isinstance(obj, UUID):
1820
return str(obj)
1921
return json.JSONEncoder.default(self, obj)
22+
23+
24+
def get_model_init_params_names(model):
25+
"""Retrieve all params of model init method
26+
27+
:param DeclarativeMeta model: an object from sqlalchemy
28+
:return tuple: list of init method fields names and boolean flag that init method has kwargs
29+
"""
30+
argnames, _, varkw = inspect.getfullargspec(model.__init__)[:3]
31+
if argnames:
32+
argnames.remove('self')
33+
return argnames, bool(varkw)
34+
35+
36+
def validate_model_init_params(model, params_names):
37+
"""Retrieve invalid params of model init method if it exists
38+
:param DeclarativeMeta model: an object from sqlalchemy
39+
:param list params_names: parameters names to check
40+
:return list: list of invalid fields or None
41+
"""
42+
init_args, has_kwargs = get_model_init_params_names(model)
43+
if has_kwargs:
44+
return
45+
46+
invalid_params = [name for name in params_names if name not in init_args]
47+
if invalid_params:
48+
return invalid_params

tests/test_errors.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from unittest.mock import Mock
2+
3+
from flask_combo_jsonapi import JsonApiException
4+
from flask_combo_jsonapi.errors import format_http_exception
5+
6+
7+
def test_format_http_exception__value_error():
8+
ex = Mock()
9+
ex.code = 'f405'
10+
11+
assert format_http_exception(ex) is None
12+
13+
14+
def test_format_http_exception__type_error():
15+
ex = Mock()
16+
ex.code = 'not_int'
17+
18+
assert format_http_exception(ex) is None
19+
20+
21+
def test_format_http_exception__success():
22+
ex = Mock()
23+
ex.code = 400
24+
25+
res = format_http_exception(ex)
26+
27+
assert isinstance(res, JsonApiException)
28+
assert res.status == '400'

tests/test_resource.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
from marshmallow_jsonapi import fields, Schema
5+
from sqlalchemy import Column, Integer, String
6+
from sqlalchemy.ext.declarative import declarative_base
7+
8+
from flask_combo_jsonapi import ResourceList
9+
10+
11+
@pytest.fixture(scope='module')
12+
def base():
13+
yield declarative_base()
14+
15+
16+
@pytest.fixture(scope='module')
17+
def model(base):
18+
class SampleModel(base):
19+
__tablename__ = 'model_sample'
20+
21+
id = Column(Integer, primary_key=True, index=True)
22+
key1 = Column(String)
23+
key2 = Column(String)
24+
25+
def __init__(self, key1):
26+
pass
27+
28+
yield SampleModel
29+
30+
31+
@pytest.fixture(scope='module')
32+
def schema_for_model(model):
33+
class SampleSchema(Schema):
34+
class Meta:
35+
model = model
36+
37+
id = fields.Integer()
38+
key1 = fields.String()
39+
key2 = fields.String()
40+
41+
yield SampleSchema
42+
43+
44+
def test_resource_meta_init(model, schema_for_model):
45+
expected_fields = ['id', 'key2']
46+
raised_ex = None
47+
try:
48+
class SampleResourceList(ResourceList):
49+
schema = schema_for_model
50+
methods = ['GET', 'POST']
51+
data_layer = {
52+
'session': Mock(),
53+
'model': model,
54+
}
55+
except Exception as ex:
56+
raised_ex = ex
57+
58+
assert raised_ex
59+
message = f"Construction of SampleResourceList failed. Schema '{schema_for_model.__name__}' " \
60+
f"has fields={expected_fields}"
61+
assert message in str(raised_ex)

tests/test_sqlalchemy_data_layer.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class StringJsonAttributePerson(base):
9898
# This model uses a String type for "json_tags" to avoid dependency on a nonstandard SQL type in testing, \
9999
# while still demonstrating support
100100
address = Column(MagicJSON)
101+
tags = Column(MagicJSON)
101102

102103
yield StringJsonAttributePerson
103104

@@ -136,7 +137,10 @@ class Computer(base):
136137

137138

138139
@pytest.fixture(scope="module")
139-
def engine(person_tag_model, person_single_tag_model, person_model, computer_model, string_json_attribute_person_model):
140+
def engine(
141+
person_tag_model, person_single_tag_model, person_model,
142+
computer_model, string_json_attribute_person_model
143+
):
140144
engine = create_engine("sqlite:///:memory:")
141145
person_tag_model.metadata.create_all(engine)
142146
person_single_tag_model.metadata.create_all(engine)
@@ -245,6 +249,7 @@ class Meta:
245249
name = fields.Str(required=True)
246250
birth_date = fields.DateTime()
247251
address = fields.Nested(address_schema, many=False)
252+
tags = fields.List(fields.Dict())
248253

249254
yield StringJsonAttributePersonSchema
250255

@@ -774,6 +779,35 @@ def test_post_list_nested(client, register_routes, computer):
774779
assert response.status_code == 201
775780
assert json.loads(response.get_data())["data"]["attributes"]["tags"][0]["key"] == "k1"
776781

782+
def test_post_list_nested_field(client, register_routes):
783+
"""
784+
Test a schema contains a nested field is correctly serialized and deserialized
785+
"""
786+
payload = {
787+
'data': {
788+
'type': 'string_json_attribute_person',
789+
'attributes': {
790+
'name': 'test',
791+
'tags': [
792+
{'key': 'new_key', 'value': 'new_value'}
793+
],
794+
},
795+
}
796+
}
797+
with client:
798+
response = client.post(
799+
'/string_json_attribute_persons',
800+
data=json.dumps(payload),
801+
content_type='application/vnd.api+json'
802+
)
803+
assert response.status_code == 201, response.json['errors']
804+
assert json.loads(
805+
response.get_data()
806+
)['data']['attributes']['tags'][0]['key'] == 'new_key'
807+
assert json.loads(
808+
response.get_data()
809+
)['data']['attributes']['tags'][0]['value'] == 'new_value'
810+
777811

778812
def test_post_list_single(client, register_routes, person):
779813
payload = {

tests/test_utils.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import pytest
2+
from sqlalchemy import Column, Integer, String
3+
from sqlalchemy.ext.declarative import declarative_base
4+
5+
from flask_combo_jsonapi.utils import get_model_init_params_names, validate_model_init_params
6+
7+
8+
@pytest.fixture(scope='module')
9+
def base():
10+
yield declarative_base()
11+
12+
13+
@pytest.fixture(scope='module')
14+
def model_without_init(base):
15+
class ModelWithoutInit(base):
16+
__tablename__ = 'model_without_init'
17+
18+
id = Column(Integer, primary_key=True, index=True)
19+
key1 = Column(String)
20+
key2 = Column(String)
21+
22+
yield ModelWithoutInit
23+
24+
25+
@pytest.fixture(scope='module')
26+
def model_with_kwargs_in_init(base):
27+
class ModelWithKwargsInit(base):
28+
__tablename__ = 'model_with_kwargs_init'
29+
30+
id = Column(Integer, primary_key=True, index=True)
31+
key1 = Column(String)
32+
key2 = Column(String)
33+
34+
def __init__(self, key1=0, **kwargs):
35+
pass
36+
37+
yield ModelWithKwargsInit
38+
39+
40+
@pytest.fixture(scope='module')
41+
def model_with_positional_args_init(base):
42+
class ModelWithPositionalArgsInit(base):
43+
__tablename__ = 'model_with_positional_args_init'
44+
45+
id = Column(Integer, primary_key=True, index=True)
46+
key1 = Column(String)
47+
key2 = Column(String)
48+
49+
def __init__(self, key1, key2=0):
50+
pass
51+
52+
yield ModelWithPositionalArgsInit
53+
54+
55+
def test_get_model_init_params_names(model_without_init, model_with_kwargs_in_init,
56+
model_with_positional_args_init):
57+
args, has_kwargs = get_model_init_params_names(model_without_init)
58+
assert ([], True) == (args, has_kwargs)
59+
60+
args, has_kwargs = get_model_init_params_names(model_with_kwargs_in_init)
61+
assert (['key1'], True) == (args, has_kwargs)
62+
63+
args, has_kwargs = get_model_init_params_names(model_with_positional_args_init)
64+
assert (['key1', 'key2'], False) == (args, has_kwargs)
65+
66+
67+
def test_validate_model_init_params(model_with_kwargs_in_init, model_with_positional_args_init):
68+
schema_attrs = ['id', 'key1', 'key2']
69+
invalid_params = validate_model_init_params(model_with_kwargs_in_init, schema_attrs)
70+
assert invalid_params is None
71+
72+
invalid_params = validate_model_init_params(model_with_positional_args_init, schema_attrs)
73+
assert invalid_params == ['id']

0 commit comments

Comments
 (0)