python-restx / flask-restx

Fork of Flask-RESTPlus: Fully featured framework for fast, easy and documented API development with Flask
https://flask-restx.readthedocs.io/en/latest/
Other
2.16k stars 335 forks source link

payload validation error when route is hidden #531

Open robertofalk opened 1 year ago

robertofalk commented 1 year ago

Code

from flask import Flask
from flask_restx import Api, Resource, Namespace
from flask_restx.fields import String, Nested

app = Flask(__name__)
api = Api(app)

ns = Namespace('app', path='/', validate=True)

todo_nested_model = ns.model("nested model", {
    'field_c': String(example="<field c>")
})

todo_model_request = ns.model('tenantEventRequest', {
    'field_a': String(required=True, example="<field a>"),
    'field_b': Nested(todo_nested_model)
})

@ns.route('/todo', doc=False)
class HiddenTodo(Resource):
    @ns.expect(todo_model_request, validate=True)
    def post(self):
        return "All good", 200

if __name__ == '__main__':
    api.add_namespace(ns)
    app.run()

Please note the doc=False, in the route definition.

Repro Steps

  1. Happy path. Calling the endpoint without the nested structure.
    curl --request POST \
    --url http://localhost:5000/todo \
    --header 'content-type: application/json' \
    --header 'user-agent: vscode-restclient' \
    --data '{"field_a": "a"}'
    "All good"
  2. Happy path (now with proper error treatment). Calling the endpoint without the nested structure but misspelling the field_a attribute.
    curl --request POST \
    --url http://localhost:5000/todo \
    --header 'content-type: application/json' \
    --header 'user-agent: vscode-restclient' \
    --data '{"field_aa": "a"}'
    {"errors": {"field_a": "'field_a' is a required property"}, "message": "Input payload validation failed"}
  3. Broken! Calling the endpoint including the nested structure.
    curl --request POST \
    --url http://localhost:5000/todo \
    --header 'content-type: application/json' \
    --header 'user-agent: vscode-restclient' \
    --data '{"field_a": "a","field_b": {"field_c": "c"}}'
    {"message": "Internal Server Error"}

Expected Behavior

The 3rd request should succeed (200 - "All good"), because there is nothing wrong with the json.

Actual Behavior

The json validation fails and the error is not treated.

Error Messages/Stack Trace

127.0.0.1 - - [23/Mar/2023 10:56:01] "POST /todo HTTP/1.1" 500 -
[2023-03-23 10:56:27,500] ERROR in app: Exception on /todo [POST]
Traceback (most recent call last):
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/validators.py", line 966, in resolve_fragment
    document = document[part]
KeyError: 'definitions'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/vscode/.local/lib/python3.8/site-packages/flask/app.py", line 1823, in full_dispatch_request
    rv = self.dispatch_request()
  File "/home/vscode/.local/lib/python3.8/site-packages/flask/app.py", line 1799, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
  File "/home/vscode/.local/lib/python3.8/site-packages/flask_restx/api.py", line 404, in wrapper
    resp = resource(*args, **kwargs)
  File "/home/vscode/.local/lib/python3.8/site-packages/flask/views.py", line 107, in view
    return current_app.ensure_sync(self.dispatch_request)(**kwargs)
  File "/home/vscode/.local/lib/python3.8/site-packages/flask_restx/resource.py", line 44, in dispatch_request
    self.validate_payload(meth)
  File "/home/vscode/.local/lib/python3.8/site-packages/flask_restx/resource.py", line 90, in validate_payload
    self.__validate_payload(expect, collection=False)
  File "/home/vscode/.local/lib/python3.8/site-packages/flask_restx/resource.py", line 75, in __validate_payload
    expect.validate(data, self.api.refresolver, self.api.format_checker)
  File "/home/vscode/.local/lib/python3.8/site-packages/flask_restx/model.py", line 96, in validate
    validator.validate(data)
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/validators.py", line 313, in validate
    for error in self.iter_errors(*args, **kwargs):
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/validators.py", line 288, in iter_errors
    for error in errors:
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/_validators.py", line 332, in properties
    yield from validator.descend(
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/validators.py", line 305, in descend
    for error in self.evolve(schema=schema).iter_errors(instance):
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/validators.py", line 288, in iter_errors
    for error in errors:
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/_validators.py", line 294, in ref
    scope, resolved = validator.resolver.resolve(ref)
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/validators.py", line 898, in resolve
    return url, self._remote_cache(url)
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/validators.py", line 916, in resolve_from_url
    return self.resolve_fragment(document, fragment)
  File "/home/vscode/.local/lib/python3.8/site-packages/jsonschema/validators.py", line 968, in resolve_fragment
    raise exceptions.RefResolutionError(
jsonschema.exceptions.RefResolutionError: Unresolvable JSON pointer: 'definitions/nested model'

Environment

Additional Context

One more interesting behaviour, if I define a second route, not hidden this time, and use the same ID for the namespace model ("nested model" in the example above), the payload validation will take this model during the validation, even if the hidden endpoint is called, and the request will succeed:

from flask import Flask
from flask_restx import Api, Resource, Namespace
from flask_restx.fields import String, Nested

app = Flask(__name__)
api = Api(app)

ns = Namespace('app', path='/', validate=True)

todo_nested_model = ns.model("nested model", {
    'field_c': String(example="<field c>")
})

todo_model_request = ns.model('tenantEventRequest', {
    'field_a': String(required=True, example="<field a>"),
    'field_b': Nested(todo_nested_model)
})

@ns.route('/todo', doc=False)
class HiddenTodo(Resource):
    @ns.expect(todo_model_request, validate=True)
    def post(self):
        return "All good", 200

todo2_nested_model = ns.model("nested model", {
    'field_c': String(example="<field c>")
})

todo2_model_request = ns.model('tenantEventRequest', {
    'field_a': String(required=True, example="<field a>"),
    'field_b': Nested(todo2_nested_model)
})

@ns.route('/todo2')
class Todo(Resource):
    @ns.expect(todo2_model_request, validate=True)
    def post(self):
        return "All good", 200

if __name__ == '__main__':
    api.add_namespace(ns)
    app.run()

Now the request simply works:

curl --request POST \
  --url http://localhost:5000/todo \
  --header 'content-type: application/json' \
  --header 'user-agent: vscode-restclient' \
  --data '{"field_a": "a","field_b": {"field_c": "c"}}'
"All good"
peter-doggart commented 1 year ago

I can't dig into the flask-restx code on this right now to investigate the source of the bug, but this is definitely related to the nested model not being registered automatically when not using the docs. If you tell it to include all models using the global config, it works as expected.

from flask import Flask
from flask_restx import Api, Resource, Namespace
from flask_restx.fields import String, Nested

app = Flask(__name__)
api = Api(app)

# Tell RESTX to include all models.
app.config["RESTX_INCLUDE_ALL_MODELS"] = True

ns = Namespace('app', path='/', validate=True)

todo_nested_model = ns.model("nested model", {
    'field_c': String(example="<field c>")
})

todo_model_request = ns.model('tenantEventRequest', {
    'field_a': String(required=True, example="<field a>"),
    'field_b': Nested(todo_nested_model)
})

@ns.route('/todo', doc=False)
class HiddenTodo(Resource):
    @ns.expect(todo_model_request, validate=True)
    def post(self):
        return "All good", 200

if __name__ == '__main__':
    api.add_namespace(ns)
    app.run()
curl --request POST \
  --url http://localhost:5000/todo \
  --header 'content-type: application/json' \
  --header 'user-agent: vscode-restclient' \
  --data '{"field_a": "a","field_b": {"field_c": "c"}}'
"All good"
robertofalk commented 1 year ago

I confirm that using app.config["RESTX_INCLUDE_ALL_MODELS"] = True solves the problem.