utils: factorize json schema validation (#86422)

It was already used in two places (views.py and photon connector), and
wrongly (photon connector was ignoring some errors for no reason).

META_SCHEMA manipulation is removed and will be replaced by a
normalization of the schema to remove lazy strings in a later commit.

A new JSONValidationError subclass of APIError is introduced.
This commit is contained in:
Benjamin Dauvergne 2024-02-01 12:46:41 +01:00
parent 45f6ee9e8d
commit f2b64b6ebf
10 changed files with 75 additions and 36 deletions

View File

@ -23,11 +23,11 @@ from django.db.models import JSONField
from django.utils.encoding import force_bytes
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from jsonschema import ValidationError, validate, validators
from requests import RequestException
from passerelle.base.models import BaseResource
from passerelle.utils.api import endpoint
from passerelle.utils.json import JSONValidationError, validate_schema
from passerelle.utils.jsonresponse import APIError
GEOJSON_SCHEMA = {
@ -136,16 +136,11 @@ class Photon(BaseResource):
return result
def validate_geojson(self, response_json):
validator = validators.validator_for(GEOJSON_SCHEMA)
validator.META_SCHEMA['properties'].pop('description', None)
validator.META_SCHEMA['properties'].pop('title', None)
try:
validate(response_json, GEOJSON_SCHEMA)
except ValidationError as e:
error_msg = e.message
if e.path:
error_msg = '%s: %s' % ('/'.join(map(str, e.path)), error_msg)
raise APIError(error_msg)
validate_schema(response_json, GEOJSON_SCHEMA)
except JSONValidationError as e:
# do not return an HTTP 400 status
raise APIError(str(e))
@endpoint(
pattern='(?P<q>.+)?$',

View File

@ -31,6 +31,9 @@
import re
from jsonschema import ValidationError, validators
from passerelle.utils.jsonresponse import APIError
from passerelle.utils.validation import is_number
FLATTEN_SEPARATOR = '/'
@ -201,3 +204,30 @@ def datasource_array_schema():
def datasource_schema():
return response_schema(datasource_array_schema())
VALIDATOR_CACHE = {}
def get_validator_for_schema(schema):
if id(schema) not in VALIDATOR_CACHE:
validator_cls = validators.validator_for(schema)
validator_cls.check_schema(schema)
validator = validator_cls(schema)
VALIDATOR_CACHE[id(schema)] = validator
return VALIDATOR_CACHE[id(schema)]
class JSONValidationError(APIError):
http_status = 400
def validate_schema(instance, schema):
validator = get_validator_for_schema(schema)
try:
validator.validate(instance)
except ValidationError as e:
error_msg = e.message
if e.path:
error_msg = '%s: %s' % ('/'.join(map(str, e.path)), error_msg)
raise JSONValidationError(error_msg)

View File

@ -52,11 +52,10 @@ from django.views.generic import (
)
from django.views.generic.detail import SingleObjectMixin
from django.views.static import serve
from jsonschema import ValidationError, validate, validators
from passerelle.base.models import BaseResource, ResourceLog
from passerelle.utils.conversion import normalize
from passerelle.utils.json import unflatten
from passerelle.utils.json import unflatten, validate_schema
from passerelle.utils.jsonresponse import APIError, JSONEncoder
from passerelle.utils.paginator import InfinitePaginator
@ -421,17 +420,8 @@ class GenericEndpointView(GenericConnectorMixin, SingleObjectMixin, View):
if pre_process is not None:
pre_process(self.endpoint.__self__, data)
# disable validation on description and title in order to allow lazy translation strings
validator = validators.validator_for(json_schema)
validator.META_SCHEMA['properties'].pop('description', None)
validator.META_SCHEMA['properties'].pop('title', None)
try:
validate(data, json_schema)
except ValidationError as e:
error_msg = e.message
if e.path:
error_msg = '%s: %s' % ('/'.join(map(str, e.path)), error_msg)
raise APIError(error_msg, http_status=400)
validate_schema(data, json_schema)
d['post_data'] = data
return d

View File

@ -175,7 +175,7 @@ def test_upload(app, connector, monkeypatch):
assert response.json == {
'data': None,
'err': 1,
'err_class': 'passerelle.utils.jsonresponse.APIError',
'err_class': 'passerelle.utils.json.JSONValidationError',
'err_desc': "file: 'content' is a required property",
}
@ -185,7 +185,7 @@ def test_upload(app, connector, monkeypatch):
assert response.json == {
'data': None,
'err': 1,
'err_class': 'passerelle.utils.jsonresponse.APIError',
'err_class': 'passerelle.utils.json.JSONValidationError',
'err_desc': "'file' is a required property",
}

View File

@ -260,8 +260,7 @@ def test_update_appointment_error(app, connector):
resp = app.post_json(endpoint + '?id=94PEP4', params=payload, status=400)
assert resp.json['err']
assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
assert resp.json['err_desc'] == "idSys: 42 is not of type 'string'"
assert 'JSONValidationError' in resp.json['err_class']
def test_get_appointment(app, connector):

View File

@ -162,7 +162,7 @@ def test_demandes_recues_bad_coordinate(app, connector, geom, err_desc):
resp = app.post_json('/litteralis/slug-litteralis/demandes-recues', params=params, status=400)
json_resp = resp.json
assert json_resp['err'] == 1
assert json_resp['err_class'] == 'passerelle.utils.jsonresponse.APIError'
assert 'Error' in json_resp['err_class']
assert json_resp['err_desc'] == err_desc

View File

@ -118,7 +118,7 @@ def test_pdf_assemble(mocked_check_output, app, pdf):
payload = {'filename': 'out.pdf', 'files/0': 42}
resp = app.post_json(endpoint, params=payload, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == "42 is not of type 'object'"
assert resp.json['err_class'] == 'passerelle.utils.json.JSONValidationError'
resp = app.get(endpoint, status=405)

View File

@ -363,7 +363,7 @@ def test_schemas(connector, soap):
def test_say_hello_method_validation_error(connector, soap, app):
resp = app.get('/soap/test/method/sayHello/')
resp = app.get('/soap/test/method/sayHello/', status=400)
assert resp.json == {
'err': 1,
'err_class': 'passerelle.utils.soap.SOAPValidationError',

View File

@ -3681,7 +3681,7 @@ def test_update_coordinate_schema_error(con, app):
}
resp = app.post_json(url + '?NameID=local&rl_id=613878', params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == "'true more text' is not of type 'boolean'"
assert 'JSONValidationError' in resp.json['err_class']
def test_update_coordinate_wrong_referential_key_error(con, app):
@ -4348,7 +4348,7 @@ def test_update_child_pai_wrong_payload_type_error(con, app):
resp = app.post_json(url + '?NameID=local&child_id=613878', params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == "42 is not of type 'string'"
assert 'JSONValidationError' in resp.json['err_class']
def test_update_child_pai_empty_referential_key_error(con, app):
@ -4568,8 +4568,7 @@ def test_update_rl_indicator_schema_error(value, con, app):
Link.objects.create(resource=con, family_id='1312', name_id='local')
resp = app.post_json(url + '?NameID=local&rl_id=613878', params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
assert "is not of type 'boolean'" in resp.json['err_desc']
assert 'JSONValidationError' in resp.json['err_class']
def test_update_rl_indicator_no_indicator_error(con, app):
@ -8938,7 +8937,7 @@ def test_add_person_basket_subscription_with_indicators_schema_error(con, app):
}
resp = app.post_json(url + '?family_id=311323', params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == "'plop' is not of type 'boolean'"
assert 'JSONValidationError' in resp.json['err_class']
def test_add_person_basket_subscription_with_indicators_wrong_referential_key_error(

View File

@ -29,11 +29,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import jsonschema
import pytest
from django.utils.translation import gettext_lazy
from passerelle.utils.json import FLATTEN_SEPARATOR as SEP
from passerelle.utils.json import flatten, flatten_json_schema, unflatten
from passerelle.utils.json import flatten, flatten_json_schema, unflatten, validate_schema
from passerelle.utils.jsonresponse import JSONEncoder
def test_unflatten_base():
@ -198,3 +202,25 @@ def test_flatten_dict_schema():
},
'additionalProperties': False,
}
@pytest.mark.parametrize(
'instance,schema',
[
(
{
'foo': 'bar',
},
{
'type': 'object',
'title': gettext_lazy('test'),
'properties': {
'foo': {'type': 'string'},
},
},
),
],
)
def test_validate_schema(instance, schema):
validate_schema(instance, schema)
json.dumps(instance, cls=JSONEncoder)