diff --git a/CHANGES.md b/CHANGES.md index ee96755..69e751c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,11 +1,45 @@ -## Versions 1.2.0 - -- [1.2.0 milestone](https://github.com/apiflask/apiflask/milestone/12) -- [1.2.0 kanban](https://github.com/apiflask/apiflask/projects/3) +## Version 1.2.1 Released: - +## Version 1.2.0 + +Released: 2023/1/8 + +- **[Breaking change]** Add `apiflask.views.MethodView` to replace `flask.views.MethodView`, raise error if + using `flask.views.MethodView` ([issue #341][issue_341]). +- **[Breaking change]** Change the status code of request validation error from 400 to 422 ([issue #345][issue_345]). +- Add `Enum` field from marshmallow 3.18. +- Fix OpenAPI spec generating for path parameters when path schema is provided ([issue #350][issue_350]). +- Add `spec_plugins` param to `APIFlask` class to support using custom apispec plugins ([issue #349][issue_349]). +- Improve the default bypassing rules to support bypass blueprint's static endpoint and + Flask-DebugToolbar ([issue #344][issue_344], [issue #369][issue_369]). +- Explicitly check if `view_func.view_class` is `MethodViewType` in `add_url_rule` ([issue #379][issue_379]). +- The schema fields are now in order by default, which ensures the output of `flask spec` is deterministic + ([issue #373][issue_373]). + +[issue_350]: https://github.com/apiflask/apiflask/issues/350 +[issue_349]: https://github.com/apiflask/apiflask/issues/349 +[issue_345]: https://github.com/apiflask/apiflask/issues/345 +[issue_344]: https://github.com/apiflask/apiflask/issues/344 +[issue_369]: https://github.com/apiflask/apiflask/issues/369 +[issue_379]: https://github.com/apiflask/apiflask/issues/379 +[issue_373]: https://github.com/apiflask/apiflask/issues/373 +[issue_341]: https://github.com/apiflask/apiflask/issues/341 + + +## Version 1.1.3 + +Released: 2022/9/4 + +- Fix some tests and import statements for Flask 2.2 ([pr #343][pr_343]). +- Pin Flask < 2.2 as a temp fix for the breaking changes of class-based view support ([issue #341][issue_341]). + +[pr_343]: https://github.com/apiflask/apiflask/pull/343 +[issue_341]: https://github.com/apiflask/apiflask/issues/341 + + ## Version 1.1.2 Released: 2022/8/13 diff --git a/README.md b/README.md index c51892a..1c1915f 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ def update_pet(pet_id, data): from apiflask import APIFlask, Schema, abort from apiflask.fields import Integer, String from apiflask.validators import Length, OneOf -from flask.views import MethodView +from apiflask.views import MethodView app = APIFlask(__name__) @@ -199,8 +199,6 @@ class PetOut(Schema): category = String() -# “app.route”只是快捷方式,你也可以直接使用“app.add_url_rule” -@app.route('/') class Hello(MethodView): # 使用 HTTP 方法名作为类方法名 @@ -208,7 +206,6 @@ class Hello(MethodView): return {'message': 'Hello!'} -@app.route('/pets/') class Pet(MethodView): @app.output(PetOut) @@ -227,6 +224,10 @@ class Pet(MethodView): for attr, value in data.items(): pets[pet_id][attr] = value return pets[pet_id] + + +app.add_url_rule('/', view_func=Hello.as_view('hello')) +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) ``` @@ -311,6 +312,7 @@ APIFlsak 是 Flask 之上的一层包装。你只需要记住下面几点区别 - 当创建程序实例时,使用 `APIFlask` 而不是 `Flask`。 - 当创建蓝本实例时,使用 `APIBlueprint` 而不是 `Blueprint`。 +- 当创建类视图时,使用 `apiflask.views.MethodView` 而不是 `flask.views.MethodView`。 - APIFlask 提供的 `abort()` 函数(`apiflask.abort`)返回 JSON 错误响应。 下面的 Flask 程序: @@ -348,11 +350,11 @@ def hello(): APIFlask 接受 marshmallow schema 作为数据 schema,它使用 webargs 验证请求数据是否符合 schema 定义,并且使用 apispec 生成 schema 对应的 OpenAPI 表示。 -你可以像以前那样构建 marshmallow schema。对于一些常用的 marshmallow 函数和类,你可以选择从 APIFlask 导入(你也可以直接从 marshmallow 导入): +你可以像以前那样构建 marshmallow schema。对于一些常用的 marshmallow 函数和类,你可以从 APIFlask 导入: - `apiflask.Schema`:schema 基类。 -- `apiflask.fields`:marshmallow 字段,包含来自 marshmallow、Flask-Marshmallow 和 webargs 的字段类。注意,别名字段(`Url`、`Str`、`Int`、`Bool` 等)已被移除(在 [marshmallow #1828](https://github.com/marshmallow-code/marshmallow/issues/1828) 投票移除这些别名字段)。 -- `apiflask.validators`:marshmallow 验证器(在 [marshmallow #1829](https://github.com/marshmallow-code/marshmallow/issues/1829) 投票为验证器相关的 API 使用更好的命名)。 +- `apiflask.fields`:marshmallow 字段,包含来自 marshmallow、Flask-Marshmallow 和 webargs 的字段类。注意,别名字段(`Url`、`Str`、`Int`、`Bool` 等)已被移除。 +- `apiflask.validators`:marshmallow 验证器投票为验证器相关的 API 使用更好的命名)。 ```python from apiflask import Schema diff --git a/docs/comparison.md b/docs/comparison.md index 11f92b1..5372735 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -121,7 +121,7 @@ def create_pet(): - Add an auto-summary for the view function based on the name of view functions. - Add success response (200) for a bare view function that only uses route decorators. -- Add validation error response (400) for view functions that use `input` decorator. +- Add validation error response (422) for view functions that use `input` decorator. - Add authentication error response (401) for view functions that use `auth_required` decorator. - Add 404 response for view functions that contain URL variables. - Add response schema for potential error responses of view function passed with `doc` decorator. For example, `doc(responses=[404, 405])`. diff --git a/docs/configuration.md b/docs/configuration.md index 6b01fd1..9460fe8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -711,7 +711,7 @@ app.config['NOT_FOUND_DESCRIPTION'] = 'Missing' The status code of validation error response. - Type: `int` -- Default value: `400` +- Default value: `422` - Examples: ```python diff --git a/docs/error-handling.md b/docs/error-handling.md index 3b842b5..4efaa79 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -26,7 +26,7 @@ description. However, in APIFlask, these errors will be returned in JSON format the following preset fields: - `message`: The HTTP reason phrase or a custom error description. -- `detail`: An empty dict (404/405/500) or the error details of the request validation (400). +- `detail`: An empty dict (404/405/500) or the error details of the request validation (422). You can control this behavior with the `json_errors` parameter when creating the APIFlask instance, and it defaults to `True`: @@ -231,7 +231,7 @@ The error object is an instance of [`HTTPError`][apiflask.exceptions.HTTPError], so you can get error information via its attributes: - status_code: If the error is triggered by a validation error, the value will be - 400 (default) or the value you passed in config `VALIDATION_ERROR_STATUS_CODE`. + 422 (default) or the value you passed in config `VALIDATION_ERROR_STATUS_CODE`. If the error is triggered by [`HTTPError`][apiflask.exceptions.HTTPError] or [`abort`][apiflask.exceptions.abort], it will be the status code you passed. Otherwise, it will be the status code set by Werkzueg when diff --git a/docs/migrating.md b/docs/migrating.md index d4f9b2e..4ae9f05 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -81,11 +81,10 @@ APIFlask support to use the `MethodView`-based view class, for example: ```python from apiflask import APIFlask, Schema, input, output -from flask.views import MethodView +from apiflask.views import MethodView # ... -@app.route('/pets/', endpoint='pet') class Pet(MethodView): decorators = [doc(responses=[404])] @@ -107,10 +106,9 @@ class Pet(MethodView): @app.output(PetOut) def patch(self, pet_id, data): pass -``` -APIFlask supports to use the `route` decorator on a `MethodView`-based view class as a -shortcut, but you can also use the `add_url_rule` method to register it for flexibility. +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) +``` The `View`-based view class is not supported, you can still use it but currently APIFlask can't generate OpenAPI spec (and API documentation) for it. diff --git a/docs/openapi.md b/docs/openapi.md index ad6dc95..304c654 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -41,6 +41,22 @@ a spec process function to update the spec before it returns. See *[Register a spec processor](#register-a-spec-processor)* for more details. +### Automation behaviors + +When generating the OpenAPI spec from your code, APIFlask has some automation behaviors: + +- Generate a default operation summary from the name of the view function. +- Generate a default operation description from the docstring of the view function. +- Generate tags from the name of blueprints. +- Add a default 200 response for any views registered to the application. +- Add a 422 response if the view is decorated with `app.input`. +- Add a 401 response if the view is decorated with `app.auth_required`. +- Add a 404 response if the view's URL rule contains variables. + +All these automation behaviors can be disabled with +[the corresponding configurations](/configuration/#automation-behavior-control). + + ### The spec format The default format of the OpenAPI spec is JSON, while YAML is also supported. @@ -431,7 +447,7 @@ def get_pet(pet_id): There are some automatic behaviors on operation `responses` object: - If the `input` decorator is added to the view function, APIFlask will add -a `400` response. +a `422` response. - When the `auth_required` decorator is added to the view function, APIFlask will add a `401` response. - If the view function only use the route decorator, APIFlask will add a default @@ -740,7 +756,7 @@ def hello(): ### Alternative operation `responses` As described above, APIFlask will add some responses based on the decorators you added -on the view function (200, 400, 401, 404). Sometimes you may want to add alternative +on the view function (200, 422, 401, 404). Sometimes you may want to add alternative responses the view function will return, then you can use the `@app.doc(responses=...)` parameter, it accepts the following values: diff --git a/docs/request.md b/docs/request.md index 8f3f5f6..8336748 100644 --- a/docs/request.md +++ b/docs/request.md @@ -10,7 +10,7 @@ Basic concepts on request handling: - Use one or more [`app.input()`](/api/app/#apiflask.scaffold.APIScaffold.input) to declare an input source, and use the `location` to declare the input location. - If the parsing and validating success, the data will pass to the view function. - Otherwise, a 400 error response will be returned automatically. + Otherwise, a 422 error response will be returned automatically. ## Request locations @@ -80,7 +80,7 @@ def get_article(category, article_id, query, data): Notice the argument name for URL variables (`category, article_id`) must match the variable name. -Otherwise, a 400 error response will be returned automatically. Like any other error response, +Otherwise, a 422 error response will be returned automatically. Like any other error response, this error response will contain `message` and `detail` fields: - `message` @@ -108,8 +108,8 @@ The value of `` is where the validation error happened. - status code -The default status code of validation error is 404, you can change this via the -config `AUTH_ERROR_STATUS_CODE`. +The default status code of validation error is 422, you can change this via the +config `VALIDATION_ERROR_STATUS_CODE`. ## Dict schema diff --git a/docs/schema.md b/docs/schema.md index db8c003..1e09a85 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -5,9 +5,8 @@ section first in the Basic Usage chapter for the basics of writing input and out Basic concepts on data schema: -- APIFlask schema = [marshmallow](https://github.com/marshmallow-code/marshmallow) schema. -- APIFlask's `apiflask.Schema` base class is directly imported from marshmallow, see the - [API documentation](https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html) +- APIFlask's `apiflask.Schema` base class is directly imported from marshmallow with some minor changes, + see the [API documentation](https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html) for the details. - We recommend separating input and output schema. Since the output data is not validated, you don't need to define validators on output fields. diff --git a/docs/usage.md b/docs/usage.md index ab8d93c..b786b8f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -557,7 +557,7 @@ following format: Read the *[Data Schema](/schema)* chapter for the advanced topics on data schema. -Now let's add it to the view function which used to create a new pet: +Now let's add it to the view function which is used to create a new pet: ```python hl_lines="1 14" from apiflask import APIFlask, Schema, input @@ -712,7 +712,7 @@ def delete_pet(pet_id): return '' ``` -From version 0.4.0, you can use a empty dict to represent empty schema: +From version 0.4.0, you can use an empty dict to represent empty schema: ```python hl_lines="2" @app.delete('/pets/') @@ -1022,13 +1022,12 @@ You can create a group of routes under the same URL rule with the `MethodView` c Here is a simple example: ```python -from flask.views import MethodView from apiflask import APIFlask +from apiflask.views import MethodView app = APIFlask(__name__) -@app.route('/pets/', endpoint='pet') class Pet(MethodView): def get(self, pet_id): @@ -1036,37 +1035,17 @@ class Pet(MethodView): def delete(self, pet_id): return '', 204 -``` - -When creating a view class, it needs to inherit from the `MethodView` class, since APIFlask -can only generate OpenAPI spec for `MethodView`-based view classes.: - -```python -from flask.views import MethodView -@app.route('/pets/', endpoint='pet') -class Pet(MethodView): - # ... -``` -APIFlask supports to use the `route` decorator on view classes as a shortcut for `add_url_rule`: - -```python -@app.route('/pets/', endpoint='pet') -class Pet(MethodView): - # ... +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) ``` -!!! tip - - If the `endpoint` argument isn't provided, the class name will be used as - endpoint. You don't need to pass a `methods` argument, since Flask will handle - it for you. +When creating a view class, it needs to inherit from the `MethodView` class, since APIFlask +can only generate OpenAPI spec for `MethodView`-based view classes. Now, you can define view methods for each HTTP method, use the (HTTP) method name as method name: ```python -@app.route('/pets/', endpoint='pet') class Pet(MethodView): def get(self, pet_id): # triggered by GET request @@ -1083,25 +1062,18 @@ class Pet(MethodView): def patch(self, pet_id): # triggered by PATCH request return {'message': 'OK'} + + +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) ``` With the example application above, when the user sends a *GET* request to `/pets/`, the `get()` method of the `Pet` class will be called, and so on for the others. -From [version 0.10.0](/changelog/#version-0100), you can also use the `add_url_rule` method to register -view classes: - -```python -class Pet(MethodView): - # ... - -app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) -``` - -You still don't need to set the `methods`, but you will need if you want to register multiple rules -for one view classes based on the methods, this can only be achieved with `add_url_rule`. For -example, the `post` method you created above normally has a different URL rule than the others: +Normally you don't need to specify the methods, unless you want to register +multiple rules for one single view classe. For example, register the `post` method +to a different URL rule than the others: ```python class Pet(MethodView): @@ -1112,11 +1084,12 @@ app.add_url_rule('/pets/', view_func=pet_view, methods=['GET', 'PUT' app.add_url_rule('/pets', view_func=pet_view, methods=['POST']) ``` +However, you may want to create separate classes for different URL rules. + When you use decorators like `@app.input`, `@app.output`, be sure to use it on method instead of class: -```python hl_lines="4 5 9 10 11 15 16" -@app.route('/pets/', endpoint='pet') +```python class Pet(MethodView): @app.output(PetOut) @@ -1134,14 +1107,16 @@ class Pet(MethodView): @app.output(PetOut) def patch(self, pet_id, data): # ... + + +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) ``` If you want to apply a decorator for all methods, instead of repeat yourself, you can pass the decorator to the class attribute `decorators`, it accepts a list of decorators: -```python hl_lines="4" -@app.route('/pets/', endpoint='pet') +```python hl_lines="3" class Pet(MethodView): decorators = [auth_required(auth), doc(responses=[404])] @@ -1161,8 +1136,13 @@ class Pet(MethodView): @app.output(PetOut) def patch(self, pet_id, data): # ... + + +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) ``` +Read [Flask docs on class-based views](https://flask.palletsprojects.com/views/) for more information. + ## Use `abort()` to return an error response diff --git a/docs_zh/comparison.md b/docs_zh/comparison.md index 34754cd..68a66d2 100644 --- a/docs_zh/comparison.md +++ b/docs_zh/comparison.md @@ -104,7 +104,7 @@ def create_pet(): - 根据视图函数的名称为视图函数添加自动摘要(summary)。 - 为仅使用路由修饰符的裸视图函数添加成功响应(200)。 -- 为使用 `input` 装饰器的视图函数添加验证错误响应(400)。 +- 为使用 `input` 装饰器的视图函数添加验证错误响应(422)。 - 为使用 `auth_required` 装饰器的视图函数添加认证错误响应(401)。 - 为包含 URL 变量的视图函数添加 404 响应。 - 为使用 `doc` 装饰器的视图函数的潜在错误响应添加响应 schema,例如 `doc(responses=[404, 405])`。 diff --git a/docs_zh/configuration.md b/docs_zh/configuration.md index 94941d9..9460fe8 100644 --- a/docs_zh/configuration.md +++ b/docs_zh/configuration.md @@ -99,7 +99,7 @@ Read more about configuration management in from .settings import CATEGORIES # import the configuration variable - class PetInSchema(Schema): + class PetIn(Schema): name = String(required=True, validate=Length(0, 10)) category = String(required=True, validate=OneOf(CATEGORIES)) # use it ``` @@ -711,7 +711,7 @@ app.config['NOT_FOUND_DESCRIPTION'] = 'Missing' The status code of validation error response. - Type: `int` -- Default value: `400` +- Default value: `422` - Examples: ```python @@ -801,12 +801,12 @@ from apiflask.fields import String, Integer, Field app = APIFlask(__name__) -class BaseResponseSchema(Schema): +class BaseResponse(Schema): message = String() status_code = Integer() data = Field() -app.config['BASE_RESPONSE_SCHEMA'] = BaseResponseSchema +app.config['BASE_RESPONSE_SCHEMA'] = BaseResponse ``` !!! warning "Version >= 0.9.0" @@ -832,9 +832,9 @@ app.config['BASE_RESPONSE_DATA_KEY'] = 'data' This configuration variable was added in the [version 0.9.0](/changelog/#version-090). -## API documentations +## API documentation -The following configuration variables used to customize API documentations. +The following configuration variables used to customize API documentation. ### DOCS_FAVICON diff --git a/docs_zh/error-handling.md b/docs_zh/error-handling.md index 03b42fe..abb1dd7 100644 --- a/docs_zh/error-handling.md +++ b/docs_zh/error-handling.md @@ -22,7 +22,7 @@ Flask 会对 400/404/405/500 错误生成默认的错误响应。这个错误响 但在 APIFlask 中,这些错误都将以 JSON 格式返回,并带有以下的预设字段: - `message`:HTTP 原因短语或自定义的错误描述。 -- `detail`:空字典(404/405/500)或校验请求时产生的详细错误信息(400)。 +- `detail`:空字典(404/405/500)或校验请求时产生的详细错误信息(422)。 你可以在创建 APIFlask 实例时使用 `json_errors` 参数来控制这个行为,`json_errors` 的默认值为 `True`。 @@ -216,7 +216,7 @@ def my_error_processor(error): 这个错误对象是 [`HTTPError`][apiflask.exceptions.HTTPError] 的一个实例, 因此可以通过其属性来获取错误信息: -- status_code:如果这个错误是由请求校验错误触发的,该值将是 400(默认)或是配置 +- status_code:如果这个错误是由请求校验错误触发的,该值将是 422(默认)或是配置 `VALIDATION_ERROR_STATUS_CODE` 的值。如果这个错误是由 [`HTTPError`][apiflask.exceptions.HTTPError] 或 [`abort`][apiflask.exceptions.abort] 触发,该值将是传入的状态码。其他情况下,该值将是 `Werkzueg` 处理请求时设置的状态码。 diff --git a/docs_zh/migrating.md b/docs_zh/migrating.md index 1132970..ec45089 100644 --- a/docs_zh/migrating.md +++ b/docs_zh/migrating.md @@ -69,6 +69,7 @@ def create_pet(): 你可以将 `app.route()` 与快捷路由装饰器混用。需要注意的是, Flask 2.0 版本已经内置了这些方法。 + ## 基于 MethodView 的视图类 !!! warning "Version >= 0.5.0" @@ -79,11 +80,10 @@ APIFlask 支持基于 `MethodView` 的视图类,例如: ```python from apiflask import APIFlask, Schema, input, output -from flask.views import MethodView +from apiflask.views import MethodView # 从 apiflak.views 导入 # ... -@app.route('/pets/', endpoint='pet') class Pet(MethodView): decorators = [doc(responses=[404])] @@ -105,10 +105,9 @@ class Pet(MethodView): @app.output(PetOutSchema) def patch(self, pet_id, data): pass -``` -APIFlask 支持对一个基于 `MethodView` 的视图类添加 `route` 装饰器,但是你也可以使用 -`add_url_rule` 方法来灵活注册这个视图类。 +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) +``` 虽然基于 `View` 的类视图不被支持,你依然可以使用它,尽管目前 APIFlask 还无法为它生成 OpenAPI spec 以及 API 文档。 @@ -159,7 +158,7 @@ def foo(): `abort_json()` 函数在 [0.4.0 版本](/changelog/#version-040) 中被重命名为 `abort()`。 -### JSON 错误以及混用 `flask.abort()` 和 `apiflask.abort()` +### JSON 错误响应以及混用 `flask.abort()` 和 `apiflask.abort()` 当你将应用基类改为 `APIFlask` 时,所有的错误响应将会自动转换为 JSON 格式,即使你在使用 Flask 提供的 `abort()` 函数: diff --git a/docs_zh/openapi.md b/docs_zh/openapi.md index 92d2712..304c654 100644 --- a/docs_zh/openapi.md +++ b/docs_zh/openapi.md @@ -26,7 +26,7 @@ info | - | See *[Meta information](#meta-information)* servers | - | Use the configuration variable [`SERVERS`](/configuration/#servers) paths | Generate based on the routes and decorators | Use `input`, `output`, `doc` decorators and docstring components | Generate from data schema | - -security | Generate secuity info from the auth objects | Use the `auth_required` decorator +security | Generate security info from the auth objects | Use the `auth_required` decorator tags | Generate from blueprint names | See *[Tags](#tags)* externalDocs | - | Use the configuration variable [`EXTERNAL_DOCS`](/configuration/#external_docs) @@ -41,6 +41,22 @@ a spec process function to update the spec before it returns. See *[Register a spec processor](#register-a-spec-processor)* for more details. +### Automation behaviors + +When generating the OpenAPI spec from your code, APIFlask has some automation behaviors: + +- Generate a default operation summary from the name of the view function. +- Generate a default operation description from the docstring of the view function. +- Generate tags from the name of blueprints. +- Add a default 200 response for any views registered to the application. +- Add a 422 response if the view is decorated with `app.input`. +- Add a 401 response if the view is decorated with `app.auth_required`. +- Add a 404 response if the view's URL rule contains variables. + +All these automation behaviors can be disabled with +[the corresponding configurations](/configuration/#automation-behavior-control). + + ### The spec format The default format of the OpenAPI spec is JSON, while YAML is also supported. @@ -120,7 +136,7 @@ app = APIFlask(__name__, spec_path='/spec') Then the spec will be available at http://localhost:5000/spec. -!!! tips +!!! tip You can configure the MIME type of the spec response with the configuration variable `YAML_SPEC_MIMETYPE` and `JSON_SPEC_MIMETYPE`, see details in the @@ -266,7 +282,7 @@ app.config['SYNC_LOCAL_SPEC'] = True app.config['LOCAL_SPEC_PATH'] = Path(app.root_path) / 'openapi.json' ``` -!!! tips +!!! tip You can also use [`app.instance_path`](https://flask.palletsprojects.com/config/#instance-folders){target=_blank}, @@ -361,13 +377,13 @@ the tag, you can use the `APIBlueprint(tag=...)` parameter to pass a new name: ```python from apiflask import APIBlueprint -bp = APIBlueprint(__name__, 'foo', tag='New Name') +bp = APIBlueprint('foo', __name__, tag='New Name') ``` This parameter also accepts a dict: ```python -bp = APIBlueprint(__name__, 'foo', tag={'name': 'New Name', 'description': 'blah...'}) +bp = APIBlueprint('foo', __name__, tag={'name': 'New Name', 'description': 'blah...'}) ``` If you don't like this blueprint-based tagging system, surely you can do it manually. @@ -387,7 +403,7 @@ app.config['TAGS'] = [ ] ``` -!!! tips +!!! tip The `app.tags` attribute is equals to the configuration variable `TAGS`, so you can also use: @@ -413,7 +429,7 @@ on the view function: ```python hl_lines="2" @app.get('/pets/') -@app.output(PetOutSchema) +@app.output(PetOut) def get_pet(pet_id): return pets[pet_id] ``` @@ -423,7 +439,7 @@ corresponding parameters in the `output` decorator: ```python hl_lines="2" @app.get('/pets/') -@app.output(PetOutSchema, status_code=200, description='Output data of a pet') +@app.output(PetOut, status_code=200, description='Output data of a pet') def get_pet(pet_id): return pets[pet_id] ``` @@ -431,7 +447,7 @@ def get_pet(pet_id): There are some automatic behaviors on operation `responses` object: - If the `input` decorator is added to the view function, APIFlask will add -a `400` response. +a `422` response. - When the `auth_required` decorator is added to the view function, APIFlask will add a `401` response. - If the view function only use the route decorator, APIFlask will add a default @@ -450,7 +466,7 @@ on the view function: ```python hl_lines="2" @app.post('/pets') -@app.input(PetInSchema) +@app.input(PetIn) def create_pet(pet_id): pass ``` @@ -460,7 +476,7 @@ will be generated instead: ```python hl_lines="2" @app.get('/pets') -@app.input(PetQuerySchema, location='query') +@app.input(PetQuery, location='query') def get_pets(): pass ``` @@ -502,8 +518,8 @@ you passed. To set the OpenAPI spec for schema fields, you can pass a dict with the `metadata` keyword: ```python -class PetInSchema(Schema): - name = String(metatdata={'description': 'The name of the pet.'}) +class PetIn(Schema): + name = String(metadata={'description': 'The name of the pet.'}) ``` You can pass the OpenAPI schema field name as the key in this metadata dict. Currently, @@ -553,7 +569,7 @@ to `validate`: from apiflask import Schema from apiflask.fields import String -class PetInSchema(Schema): +class PetIn(Schema): name = String( required=True, validate=Length(0, 10), @@ -592,9 +608,9 @@ Normally, you only need to set the following fields manually with the `metadata` See details in *[OpenAPI XML object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#xmlObject)*. -!!! tips +!!! tip - If the schema class' name ends with `Schema`, then it will be striped in the spec. + If the schema class' name ends with `Schema`, then it will be stripped in the spec. ### Response and request example @@ -608,7 +624,7 @@ from apiflask import APIFlask, input app = APIFlask(__name__) @app.post('/pets') -@app.input(PetInSchema, example={'name': 'foo', 'category': 'cat'}) +@app.input(PetIn, example={'name': 'foo', 'category': 'cat'}) def create_pet(): pass ``` @@ -633,7 +649,7 @@ examples = { } @app.get('/pets') -@app.output(PetOutSchema, examples=examples) +@app.output(PetOut, examples=examples) def get_pets(): pass ``` @@ -645,7 +661,7 @@ def get_pets(): you can set the field example (property-level example) in the data schema: ```python - class PetQuerySchema(Schema): + class PetQuery(Schema): name = String(metadata={'example': 'Flash'}) ``` @@ -740,7 +756,7 @@ def hello(): ### Alternative operation `responses` As described above, APIFlask will add some responses based on the decorators you added -on the view function (200, 400, 401, 404). Sometimes you may want to add alternative +on the view function (200, 422, 401, 404). Sometimes you may want to add alternative responses the view function will return, then you can use the `@app.doc(responses=...)` parameter, it accepts the following values: @@ -836,7 +852,7 @@ from apiflask import APIFlask app = APIFlask(__name__, enable_openapi=False) ``` -!!! tips +!!! tip If you only need to disable the API documentation, see *[Disable the API documentations globally](/api-docs/#disable-the-api-documentations-globally)*. @@ -850,10 +866,10 @@ set `enable_openapi` parameter to `False` when creating the `APIBlueprint` insta ```python from apiflask import APIBlueprint -bp = APIBlueprint(__name__, 'foo', enable_openapi=False) +bp = APIBlueprint('foo', __name__, enable_openapi=False) ``` -!!! tips +!!! tip APIFlask will skip a blueprint if the blueprint is created by other Flask extensions. diff --git a/docs_zh/request.md b/docs_zh/request.md index d49e7f5..4986633 100644 --- a/docs_zh/request.md +++ b/docs_zh/request.md @@ -7,7 +7,7 @@ - APIFlask 使用 [webargs](https://github.com/marshmallow-code/webargs) 解析和验证请求。 - 使用一个或多个 [`app.input()`](/api/app/#apiflask.scaffold.APIScaffold.input) 来声明请求数据来源, 并使用 `location` 来声明请求数据位置。 -- 假如解析与验证均成功,数据将会被传至视图函数,否则自动返回 400 错误响应。 +- 假如解析与验证均成功,数据将会被传至视图函数,否则自动返回 422 错误响应。 ## 请求数据的位置声明 @@ -73,7 +73,7 @@ def get_article(category, article_id, query, data): 注意:URL 变量对应的参数名称(`category`, `article_id`)必须与变量名相同。 -如果验证失败,APIFlask 将会自动返回 400 错误响应。与其它错误响应相同, +如果验证失败,APIFlask 将会自动返回 422 错误响应。与其它错误响应相同, 这个错误响应将会有消息(`message`)和详情(`detail`)等部分。 - 消息(`message`) @@ -100,7 +100,7 @@ def get_article(category, article_id, query, data): - 状态码 -默认的验证错误的状态码是 400,你可以在配置中更改 `VALIDATION_ERROR_STATUS_CODE` 的值来更改它。 +默认的验证错误的状态码是 422,你可以在配置中更改 `VALIDATION_ERROR_STATUS_CODE` 的值来更改它。 ## 字典 Schema diff --git a/docs_zh/schema.md b/docs_zh/schema.md index 93fd881..1e09a85 100644 --- a/docs_zh/schema.md +++ b/docs_zh/schema.md @@ -5,9 +5,8 @@ section first in the Basic Usage chapter for the basics of writing input and out Basic concepts on data schema: -- APIFlask schema = [marshmallow](https://github.com/marshmallow-code/marshmallow) schema. -- APIFlask's `apiflask.Schema` base class is directly imported from marshmallow, see the - [API documentation](https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html) +- APIFlask's `apiflask.Schema` base class is directly imported from marshmallow with some minor changes, + see the [API documentation](https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html) for the details. - We recommend separating input and output schema. Since the output data is not validated, you don't need to define validators on output fields. @@ -18,7 +17,6 @@ Basic concepts on data schema: - Read [marshmallow's documentation](https://marshmallow.readthedocs.io/) when you have free time. - ## Deserialization (load) and serialization (dump) In APIFlask (marshmallow), the process of parsing and validating the input request data @@ -43,7 +41,7 @@ And there are two decorators to register a validation method: - `validates(field_name)`: to register a method to validate a specified field - `validates_schema`: to register a method to validate the whole schema -!!! tips +!!! tip When using the `validates_schema`, notice the `skip_on_field_errors` is set to `True` as default: > If skip_on_field_errors=True, this validation method will be skipped whenever validation errors @@ -74,7 +72,7 @@ Or you prefer to keep a reference: ```python from apiflask import Schema, fields -class FooBarSchema(Schema): +class FooBar(Schema): foo = fields.String() bar = fields.Integer() ``` @@ -161,7 +159,7 @@ API documentation: - `File` -!!! tips +!!! tip If the existing fields don't fit your needs, you can also create [custom fields](https://marshmallow.readthedocs.io/en/stable/custom_fields.html). @@ -195,7 +193,7 @@ from apiflask.fields import String from apiflask.validators import OneOf -class PetInSchema(Schema): +class PetIn(Schema): category = String(required=True, validate=OneOf(['dog', 'cat'])) ``` @@ -207,11 +205,11 @@ from apiflask.fields import String from apiflask.validators import Length, OneOf -class PetInSchema(Schema): - category = String(required=True, validate=[OneOf(['dog', 'cat'], Length(0, 10)])) +class PetIn(Schema): + category = String(required=True, validate=[OneOf(['dog', 'cat']), Length(0, 10)]) ``` -!!! tips +!!! tip If the existing validators don't fit your needs, you can also create [custom validators](https://marshmallow.readthedocs.io/en/stable/quickstart.html#validation). @@ -289,7 +287,7 @@ responses to the following format: "category": "cat" }, "message": "some message", - "status_code": "custom code" + "code": "custom code" } ``` @@ -302,12 +300,12 @@ from apiflask.fields import String, Integer, Field app = APIFlask(__name__) -class BaseResponseSchema(Schema): - message = String() - status_code = Integer() +class BaseResponse(Schema): data = Field() # the data key + message = String() + code = Integer() -app.config['BASE_RESPONSE_SCHEMA'] = BaseResponseSchema +app.config['BASE_RESPONSE_SCHEMA'] = BaseResponse ``` The default data key is "data", you can change it to match your data field name in your schema @@ -323,29 +321,70 @@ Now you can return a dict matches the base response schema in your view function @app.get('/') def say_hello(): data = {'name': 'Grey'} - return {'message': 'Success!', 'status_code': 200, 'data': data} + return { + 'data': data, + 'message': 'Success!', + 'code': 200 + } ``` -To make it more elegant, you can create a function to make response dict: +Check out [the complete example application](https://github.com/apiflask/apiflask/tree/main/examples/base_response/app.py) +for more details, see [the examples page](/examples) for running the example application. + + +## Use dataclass as data schema + +With [marshmalow-dataclass](https://github.com/lovasoa/marshmallow_dataclass), you can define +dataclasses and then convert them into marshmallow schemas. + +```bash +$ pip install marshmallow-dataclass +``` + +You can use the `dataclass` decorator from marshmallow-dataclass to create the data class, then call the +`.Schema` attribute to get the corresponding marshmallow schema: ```python -def make_resp(message, status_code, data): - return {'message': message, 'status_code': status_code, 'data': data} +from dataclasses import field +from apiflask import APIFlask +from apiflask.validators import Length, OneOf +from marshmallow_dataclass import dataclass -@app.get('/') -def say_hello(): - data = {'message': 'Hello!'} - return make_resp('Success!', 200, data) + +app = APIFlask(__name__) -@app.get('/pets/') -@app.output(PetOutSchema) -def get_pet(pet_id): - if pet_id > len(pets) - 1 or pets[pet_id].get('deleted'): - abort(404) - return make_resp('Success!', 200, pets[pet_id]) +@dataclass +class PetIn: + name: str = field( + metadata={'required': True, 'validate': Length(min=1, max=10)} + ) + category: str = field( + metadata={'required': True, 'validate': OneOf(['cat', 'dog'])} + ) + + +@dataclass +class PetOut: + id: int + name: str + category: str + + +@app.post('/pets') +@app.input(PetIn.Schema) +@app.output(PetOut.Schema, status_code=201) +def create_pet(pet: PetIn): + return { + 'id': 0, + 'name': pet.name, + 'category': pet.category + } ``` -Check out [the complete example application](https://github.com/apiflask/apiflask/tree/main/examples/base_response/app.py) +Check out [the complete example application](https://github.com/apiflask/apiflask/tree/main/examples/dataclass/app.py) for more details, see [the examples page](/examples) for running the example application. + +Read [mashmallow-dataclass's documentation](https://lovasoa.github.io/marshmallow_dataclass/html/marshmallow_dataclass.html) +and [dataclasses](https://docs.python.org/3/library/dataclasses.html) for more information. diff --git a/docs_zh/usage.md b/docs_zh/usage.md index bd1b08a..b786b8f 100644 --- a/docs_zh/usage.md +++ b/docs_zh/usage.md @@ -77,15 +77,21 @@ $ flask run If your script's name isn't `app.py`, you will need to declare which application should be started before execute `flask run`. See the note below for more details. -??? note "Assign the specific application to run with `FLASK_APP`" +??? note "Assign the specific application to run" In default, Flask will look for an application instance called `app` or `application` or application factory function called `create_app` or `make_app` in module/package called `app` or `wsgi`. That's why I recommend naming the file as `app.py`. If you use a different name, then you need to tell Flask the application module path via the - environment variable `FLASK_APP`. For example, if your application instance stored in - a file called `hello.py`, then you will need to set `FLASK_APP` to the module name - `hello`: + `--app` (Flask 2.2+) option or the environment variable `FLASK_APP`. For example, if + your application instance stored in a file called `hello.py`, then you will need to + set `--app` or `FLASK_APP` to the module name `hello`: + + ``` + $ flask --app hello run + ``` + + or: === "Bash" @@ -106,7 +112,11 @@ should be started before execute `flask run`. See the note below for more detail ``` Similarly, If your application instance or application factory function stored in - `mypkg/__init__.py`, you can set `FLASK_APP` to the package name: + `mypkg/__init__.py`, you can pass the package name: + + ``` + $ flask --app mypkg run + ``` === "Bash" @@ -127,7 +137,13 @@ should be started before execute `flask run`. See the note below for more detail ``` However, if the application instance or application factory function store in - `mypkg/myapp.py`, you will need to set `FLASK_APP` to: + `mypkg/myapp.py`, you will need to use: + + ``` + $ flask --app mypkg.myapp run + ``` + + or: === "Bash" @@ -177,29 +193,35 @@ $ flask run --reload We highly recommend enabling "debug mode" when developing Flask application. See the note below for the details. -??? note "Enable the debug mode with `FLASK_ENV`" +??? note "Enable the debug mode" Flask can automatically restart and reload the application when code changes and display useful debug information for errors. To enable these features - in your Flask application, we will need to set the environment variable - `FLASK_ENV` to `development`: + in your Flask application, we will need to use the `--debug` option: + + ``` + $ flask --debug run + ``` + + If you are not using the latest Flask version (>2.2), you will need to set + the environment variable `FLASK_DEBUG` to `True` instead: === "Bash" ```bash - $ export FLASK_ENV=development + $ export FLASK_DEBUG=True ``` === "Windows CMD" ``` - > set FLASK_ENV=development + > set FLASK_DEBUG=True ``` === "Powershell" ``` - > $env:FLASK_APP="development" + > $env:FLASK_DEBUG="True" ``` See *[Debug Mode][_debug_mode]{target=_blank}* for more details. @@ -285,7 +307,7 @@ See *[Environment Variables From dotenv][_dotenv]{target=_blank}* for more detai ## Interactive API documentation Once you have created the app instance, the interactive API documentation will be -available at and . On +available at . On top of that, the OpenAPI spec file is available at . If you want to preview the spec or save the spec to a local file, use [the `flask spec` @@ -294,6 +316,8 @@ command](/openapi/#the-flask-spec-command). You can refresh the documentation whenever you added a new route or added the input and output definition for the view function in the following sections. +Read the *[API Documentations](/api-docs)* chapter for the advanced topics on API docs. + ## Create a route with route decorators @@ -419,8 +443,8 @@ from apiflask import APIFlask app = APIFlask(__name__) @app.get('/') -@app.input(FooSchema) -@app.output(BarSchema) +@app.input(Foo) +@app.output(Bar) def hello(): return {'message': 'Hello'} ``` @@ -433,8 +457,8 @@ from apiflask import APIFlask, input, output app = APIFlask(__name__) @app.get('/') -@input(FooSchema) -@output(BarSchema) +@input(Foo) +@output(Bar) def hello(): return {'message': 'Hello'} ``` @@ -459,7 +483,7 @@ from apiflask.fields import Integer, String from apiflask.validators import Length, OneOf -class PetInSchema(Schema): +class PetIn(Schema): name = String(required=True, validate=Length(0, 10)) category = String(required=True, validate=OneOf(['dog', 'cat'])) ``` @@ -477,7 +501,7 @@ from apiflask.fields import Integer, String from apiflask.validators import Length, OneOf -class PetInSchema(Schema): +class PetIn(Schema): name = String(required=True, validate=Length(0, 10)) category = String(required=True, validate=OneOf(['dog', 'cat'])) ``` @@ -490,7 +514,7 @@ from apiflask.fields import Integer, String from apiflask.validators import Length, OneOf -class PetInSchema(Schema): +class PetIn(Schema): name = String(required=True, validate=Length(0, 10)) category = String(required=True, validate=OneOf(['dog', 'cat'])) ``` @@ -505,7 +529,7 @@ from apiflask.fields import Integer, String from apiflask.validators import Length, OneOf -class PetInSchema(Schema): +class PetIn(Schema): name = String(required=True, validate=Length(0, 10)) category = String(required=True, validate=OneOf(['dog', 'cat'])) ``` @@ -533,7 +557,7 @@ following format: Read the *[Data Schema](/schema)* chapter for the advanced topics on data schema. -Now let's add it to the view function which used to create a new pet: +Now let's add it to the view function which is used to create a new pet: ```python hl_lines="1 14" from apiflask import APIFlask, Schema, input @@ -543,13 +567,13 @@ from apiflask.validators import Length, OneOf app = APIFlask(__name__) -class PetInSchema(Schema): +class PetIn(Schema): name = String(required=True, validate=Length(0, 10)) category = String(required=True, validate=OneOf(['dog', 'cat'])) @app.post('/pets') -@app.input(PetInSchema) +@app.input(PetIn) def create_pet(data): print(data) return {'message': 'created'}, 201 @@ -568,8 +592,8 @@ you can do something like this to create an ORM model instance: ```python hl_lines="5" @app.post('/pets') -@app.input(PetInSchema) -@app.output(PetOutSchema) +@app.input(PetIn) +@app.output(PetOut) def create_pet(pet_id, data): pet = Pet(**data) return pet @@ -579,8 +603,8 @@ or update an ORM model class instance like this: ```python hl_lines="6 7" @app.patch('/pets/') -@app.input(PetInSchema) -@app.output(PetOutSchema) +@app.input(PetIn) +@app.output(PetOut) def update_pet(pet_id, data): pet = Pet.query.get(pet_id) for attr, value in data.items(): @@ -598,6 +622,7 @@ argument for `@app.input()` decorator, the value can be: - Cookies: `'cookies'` - HTTP headers: `'headers'` - Query string: `'query'` (same as `'querystring'`) +- Path variable (URL variable): `'path'` (same as `'view_args'`, added in APIFlask 1.0.2) !!! warning @@ -616,7 +641,7 @@ Similarly, we can define a schema for output data with `@app.output` decorator. from apiflask.fields import String, Integer -class PetOutSchema(Schema): +class PetOut(Schema): id = Integer() name = String() category = String() @@ -642,14 +667,14 @@ from apiflask.fields import String, Integer app = APIFlask(__name__) -class PetOutSchema(Schema): +class PetOut(Schema): id = Integer() name = String() category = String() @app.get('/pets/') -@app.output(PetOutSchema) +@app.output(PetOut) def get_pet(pet_id): return { 'name': 'Coco', @@ -662,8 +687,8 @@ status code with the `status_code` argument: ```python hl_lines="3" @app.post('/pets') -@app.input(PetInSchema) -@app.output(PetOutSchema, status_code=201) +@app.input(PetIn) +@app.output(PetOut, status_code=201) def create_pet(data): data['id'] = 2 return data @@ -672,7 +697,7 @@ def create_pet(data): Or just: ```python -@app.output(PetOutSchema, 201) +@app.output(PetOut, status_code=201) ``` If you want to return a 204 response, you can use the `EmptySchema` from `apiflask.schemas`: @@ -682,16 +707,16 @@ from apiflask.schemas import EmptySchema @app.delete('/pets/') -@app.output(EmptySchema, 204) +@app.output(EmptySchema, status_code=204) def delete_pet(pet_id): return '' ``` -From version 0.4.0, you can use a empty dict to represent empty schema: +From version 0.4.0, you can use an empty dict to represent empty schema: ```python hl_lines="2" @app.delete('/pets/') -@app.output({}, 204) +@app.output({}, status_code=204) def delete_pet(pet_id): return '' ``` @@ -706,8 +731,8 @@ def delete_pet(pet_id): ```python hl_lines="4" @app.put('/pets/') - @app.input(PetInSchema) - @app.output(PetOutSchema) # 200 + @app.input(PetIn) + @app.output(PetOut) # 200 @app.doc(responses=[204, 404]) def update_pet(pet_id, data): pass @@ -732,7 +757,7 @@ from apiflask import Schema from apiflask.fields import String, Integer -class PetOutSchema(Schema): +class PetOut(Schema): id = Integer() name = String() category = String() @@ -742,7 +767,7 @@ Now you can return a dict: ```python @app.get('/pets/') -@app.output(PetOutSchema) +@app.output(PetOut) def get_pet(pet_id): return { 'id': 1, @@ -755,7 +780,7 @@ or you can return an ORM model instance directly: ```python hl_lines="5" @app.get('/pets/') -@app.output(PetOutSchema) +@app.output(PetOut) def get_pet(pet_id): pet = Pet.query.get(pet_id) return pet @@ -784,7 +809,7 @@ class Pet(Model): `data_key` to declare the actual key name to dump to: ```python - class UserOutSchema(Schema): + class UserOut(Schema): phone = String(data_key='phone_number') ``` @@ -793,7 +818,7 @@ class Pet(Model): Similarly, you can tell APIFlask to load from different key in input schema: ```python - class UserInSchema(Schema): + class UserIn(Schema): phone = String(data_key='phone_number') ``` @@ -804,8 +829,8 @@ you can pass a `status_code` argument in the `@app.output` decorator: ```python hl_lines="3" @app.post('/pets') -@app.input(PetInSchema) -@app.output(PetOutSchema, 201) +@app.input(PetIn) +@app.output(PetOut, status_code=201) def create_pet(data): # ... return pet @@ -816,8 +841,8 @@ You don't need to return the same status code in the end of the view function ```python hl_lines="8" @app.post('/pets') -@app.input(PetInSchema) -@app.output(PetOutSchema, 201) +@app.input(PetIn) +@app.output(PetOut, status_code=201) def create_pet(data): # ... # equals to: @@ -830,8 +855,8 @@ of the return tuple: ```python hl_lines="8" @app.post('/pets') -@app.input(PetInSchema) -@app.output(PetOutSchema, 201) +@app.input(PetIn) +@app.output(PetOut, status_code=201) def create_pet(data): # ... # equals to: @@ -839,7 +864,7 @@ def create_pet(data): return pet, {'FOO': 'bar'} ``` -!!! tips +!!! tip Be sure to always set the `status_code` argument in `@app.output` when you want to use a non-200 status code. If there is a mismatch, the `status_code` @@ -997,13 +1022,12 @@ You can create a group of routes under the same URL rule with the `MethodView` c Here is a simple example: ```python -from flask.views import MethodView from apiflask import APIFlask +from apiflask.views import MethodView app = APIFlask(__name__) -@app.route('/pets/', endpoint='pet') class Pet(MethodView): def get(self, pet_id): @@ -1011,37 +1035,17 @@ class Pet(MethodView): def delete(self, pet_id): return '', 204 -``` -When creating a view class, it needs to inherit from the `MethodView` class, since APIFlask -can only generate OpenAPI spec for `MethodView`-based view classes.: - -```python -from flask.views import MethodView -@app.route('/pets/', endpoint='pet') -class Pet(MethodView): - # ... -``` - -APIFlask supports to use the `route` decorator on view classes as a shortcut for `add_url_rule`: - -```python -@app.route('/pets/', endpoint='pet') -class Pet(MethodView): - # ... +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) ``` -!!! tips - - If the `endpoint` argument isn't provided, the class name will be used as - endpoint. You don't need to pass a `methods` argument, since Flask will handle - it for you. +When creating a view class, it needs to inherit from the `MethodView` class, since APIFlask +can only generate OpenAPI spec for `MethodView`-based view classes. Now, you can define view methods for each HTTP method, use the (HTTP) method name as method name: ```python -@app.route('/pets/', endpoint='pet') class Pet(MethodView): def get(self, pet_id): # triggered by GET request @@ -1058,25 +1062,18 @@ class Pet(MethodView): def patch(self, pet_id): # triggered by PATCH request return {'message': 'OK'} + + +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) ``` With the example application above, when the user sends a *GET* request to `/pets/`, the `get()` method of the `Pet` class will be called, and so on for the others. -From [version 0.10.0](/changelog/#version-0100), you can also use the `add_url_rule` method to register -view classes: - -```python -class Pet(MethodView): - # ... - -app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) -``` - -You still don't need to set the `methods`, but you will need if you want to register multiple rules -for one view classes based on the methods, this can only be achieved with `add_url_rule`. For -example, the `post` method you created above normally has a different URL rule than the others: +Normally you don't need to specify the methods, unless you want to register +multiple rules for one single view classe. For example, register the `post` method +to a different URL rule than the others: ```python class Pet(MethodView): @@ -1087,57 +1084,65 @@ app.add_url_rule('/pets/', view_func=pet_view, methods=['GET', 'PUT' app.add_url_rule('/pets', view_func=pet_view, methods=['POST']) ``` +However, you may want to create separate classes for different URL rules. + When you use decorators like `@app.input`, `@app.output`, be sure to use it on method instead of class: -```python hl_lines="4 5 9 10 11 15 16" -@app.route('/pets/', endpoint='pet') +```python class Pet(MethodView): - @app.output(PetOutSchema) + @app.output(PetOut) @app.doc(summary='Get a Pet') def get(self, pet_id): # ... @app.auth_required(auth) - @app.input(PetInSchema) - @app.output(PetOutSchema) + @app.input(PetIn) + @app.output(PetOut) def put(self, pet_id, data): # ... - @app.input(PetInSchema(partial=True)) - @app.output(PetOutSchema) + @app.input(PetIn(partial=True)) + @app.output(PetOut) def patch(self, pet_id, data): # ... + + +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) ``` If you want to apply a decorator for all methods, instead of repeat yourself, you can pass the decorator to the class attribute `decorators`, it accepts a list of decorators: -```python hl_lines="4" -@app.route('/pets/', endpoint='pet') +```python hl_lines="3" class Pet(MethodView): decorators = [auth_required(auth), doc(responses=[404])] - @app.output(PetOutSchema) + @app.output(PetOut) @app.doc(summary='Get a Pet') def get(self, pet_id): # ... @app.auth_required(auth) - @app.input(PetInSchema) - @app.output(PetOutSchema) + @app.input(PetIn) + @app.output(PetOut) def put(self, pet_id, data): # ... - @app.input(PetInSchema(partial=True)) - @app.output(PetOutSchema) + @app.input(PetIn(partial=True)) + @app.output(PetOut) def patch(self, pet_id, data): # ... + + +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) ``` +Read [Flask docs on class-based views](https://flask.palletsprojects.com/views/) for more information. + ## Use `abort()` to return an error response @@ -1217,7 +1222,7 @@ In the end, let's unpack the whole `apiflask` package to check out what it shipp - `app.route()`: A decorator used to register a route. It accepts a `methods` parameter to specify a list of accepted methods, default to *GET* only. It can also be used on the `MethodView`-based view class. -- `$ flask run`: A command to output the spec to stdout or a file. +- `$ flask spec`: A command to output the spec to stdout or a file. You can learn the details of these APIs in the [API reference](/api/app), or you can continue to read the following chapters. diff --git a/examples/cbv/app.py b/examples/cbv/app.py index 5379a38..fc6e2b0 100644 --- a/examples/cbv/app.py +++ b/examples/cbv/app.py @@ -1,7 +1,7 @@ from apiflask import APIFlask, Schema, abort from apiflask.fields import Integer, String from apiflask.validators import Length, OneOf -from flask.views import MethodView +from apiflask.views import MethodView app = APIFlask(__name__) @@ -23,16 +23,12 @@ class PetOut(Schema): category = String() -# "app.route" is just a shortcut, -# you can also use "app.add_url_rule" directly -@app.route('/') class Hello(MethodView): def get(self): return {'message': 'Hello!'} -@app.route('/pets/') class Pet(MethodView): @app.output(PetOut) @@ -62,7 +58,6 @@ def delete(self, pet_id): return '' -@app.route('/pets') class Pets(MethodView): @app.output(PetOut(many=True)) @@ -78,3 +73,8 @@ def post(self, data): data['id'] = pet_id pets.append(data) return pets[pet_id] + + +app.add_url_rule('/', view_func=Hello.as_view('hello')) +app.add_url_rule('/pets/', view_func=Pet.as_view('pet')) +app.add_url_rule('/pets', view_func=Pets.as_view('pets')) diff --git a/examples/test_examples.py b/examples/test_examples.py index c2d4705..7ccabe3 100644 --- a/examples/test_examples.py +++ b/examples/test_examples.py @@ -108,7 +108,7 @@ def test_create_pet(client): ]) def test_create_pet_with_bad_data(client, data): rv = client.post('/pets', json=data) - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json @@ -144,7 +144,7 @@ def test_update_pet(client): ]) def test_update_pet_with_bad_data(client, data): rv = client.patch('/pets/1', json=data) - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json @@ -194,7 +194,7 @@ def test_get_pets_pagination(client): @pytest.mark.parametrize('client', ['pagination'], indirect=True) def test_get_pets_pagination_with_bad_data(client): rv = client.get('/pets?per_page=100') - assert rv.status_code == 400 + assert rv.status_code == 422 rv = client.get('/pets?page=100') assert rv.status_code == 404 diff --git a/mkdocs.yml b/mkdocs.yml index 81e1a06..fd0e7b0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,9 @@ extra: - name: English link: https://apiflask.com lang: en + - name: فارسی + link: https://fa.apiflask.com + lang: fa - name: 简体中文 link: https://zh.apiflask.com lang: zh diff --git a/requirements/dev.txt b/requirements/dev.txt index 9d6c536..92b7352 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -9,33 +9,43 @@ -r examples.txt -r tests.txt -r typing.txt +build==0.9.0 + # via pip-tools +cachetools==5.2.0 + # via tox cfgv==3.3.1 # via pre-commit -distlib==0.3.4 +chardet==5.1.0 + # via tox +distlib==0.3.6 # via virtualenv -filelock==3.7.1 +filelock==3.9.0 # via # tox # virtualenv -identify==2.5.1 +identify==2.5.12 # via pre-commit -nodeenv==1.6.0 +nodeenv==1.7.0 # via pre-commit -pep517==0.12.0 - # via pip-tools -pip-compile-multi==2.4.5 +pep517==0.13.0 + # via build +pip-compile-multi==2.6.1 # via -r requirements/dev.in -pip-tools==6.6.2 +pip-tools==6.12.1 # via pip-compile-multi -platformdirs==2.5.2 - # via virtualenv -pre-commit==2.19.0 +platformdirs==2.6.2 + # via + # tox + # virtualenv +pre-commit==2.21.0 # via -r requirements/dev.in +pyproject-api==1.4.0 + # via tox toposort==1.7 # via pip-compile-multi -tox==3.25.0 +tox==4.2.6 # via -r requirements/dev.in -virtualenv==20.14.1 +virtualenv==20.17.1 # via # pre-commit # tox diff --git a/requirements/docs.txt b/requirements/docs.txt index 3c9f600..689d0d2 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -7,11 +7,19 @@ # astunparse==1.6.3 # via pytkdocs +certifi==2022.12.7 + # via requests +charset-normalizer==2.1.1 + # via requests click==8.1.3 # via mkdocs +colorama==0.4.6 + # via mkdocs-material ghp-import==2.1.0 # via mkdocs -importlib-metadata==4.11.4 +idna==3.4 + # via requests +importlib-metadata==6.0.0 # via # markdown # mkdocs @@ -33,33 +41,31 @@ markupsafe==2.1.1 # mkdocstrings mergedeep==1.3.4 # via mkdocs -mkdocs==1.3.0 +mkdocs==1.4.2 # via # mkdocs-autorefs # mkdocs-material # mkdocstrings mkdocs-autorefs==0.4.1 # via mkdocstrings -mkdocs-material==8.3.6 +mkdocs-material==9.0.3 # via -r requirements/docs.in -mkdocs-material-extensions==1.0.3 +mkdocs-material-extensions==1.1.1 # via mkdocs-material -mkdocstrings[python-legacy]==0.19.0 +mkdocstrings[python-legacy]==0.19.1 # via # -r requirements/docs.in # mkdocstrings-python-legacy mkdocstrings-python-legacy==0.2.3 # via mkdocstrings -packaging==21.3 +packaging==22.0 # via mkdocs -pygments==2.12.0 +pygments==2.14.0 # via mkdocs-material -pymdown-extensions==9.5 +pymdown-extensions==9.9 # via # mkdocs-material # mkdocstrings -pyparsing==3.0.9 - # via packaging python-dateutil==2.8.2 # via ghp-import pytkdocs==0.16.1 @@ -70,13 +76,19 @@ pyyaml==6.0 # pyyaml-env-tag pyyaml-env-tag==0.1 # via mkdocs +regex==2022.10.31 + # via mkdocs-material +requests==2.28.1 + # via mkdocs-material six==1.16.0 # via # astunparse # python-dateutil -watchdog==2.1.9 +urllib3==1.26.13 + # via requests +watchdog==2.2.1 # via mkdocs -wheel==0.37.1 +wheel==0.38.4 # via astunparse -zipp==3.8.0 +zipp==3.11.0 # via importlib-metadata diff --git a/requirements/examples.txt b/requirements/examples.txt index f0c6977..ab1b0ca 100644 --- a/requirements/examples.txt +++ b/requirements/examples.txt @@ -5,45 +5,45 @@ # # pip-compile-multi # -authlib==1.0.1 +authlib==1.2.0 # via -r requirements/examples.in -cffi==1.15.0 +cffi==1.15.1 # via cryptography click==8.1.3 # via flask -cryptography==37.0.2 +cryptography==39.0.0 # via authlib -flask==2.1.2 +flask==2.2.2 # via flask-sqlalchemy -flask-sqlalchemy==2.5.1 +flask-sqlalchemy==3.0.2 # via -r requirements/examples.in -importlib-metadata==4.11.4 +importlib-metadata==6.0.0 # via flask itsdangerous==2.1.2 # via flask jinja2==3.1.2 # via flask markupsafe==2.1.1 - # via jinja2 -marshmallow==3.16.0 + # via + # jinja2 + # werkzeug +marshmallow==3.19.0 # via marshmallow-dataclass -marshmallow-dataclass==8.5.8 +marshmallow-dataclass==8.5.10 # via -r requirements/examples.in mypy-extensions==0.4.3 # via typing-inspect -packaging==21.3 +packaging==22.0 # via marshmallow pycparser==2.21 # via cffi -pyparsing==3.0.9 - # via packaging -sqlalchemy==1.4.37 +sqlalchemy==1.4.46 # via flask-sqlalchemy -typing-extensions==4.2.0 +typing-extensions==4.4.0 # via typing-inspect -typing-inspect==0.7.1 +typing-inspect==0.8.0 # via marshmallow-dataclass -werkzeug==2.1.2 +werkzeug==2.2.2 # via flask -zipp==3.8.0 +zipp==3.11.0 # via importlib-metadata diff --git a/requirements/tests.txt b/requirements/tests.txt index 0f7e297..4dbfd37 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -5,50 +5,61 @@ # # pip-compile-multi # -asgiref==3.5.2 +asgiref==3.6.0 # via -r requirements/tests.in -attrs==21.4.0 +attrs==22.2.0 # via # jsonschema + # openapi-schema-validator # pytest -coverage[toml]==6.4.1 +coverage[toml]==7.0.4 # via pytest-cov -importlib-resources==5.8.0 - # via jsonschema -iniconfig==1.1.1 +importlib-resources==5.10.2 + # via + # jsonschema + # openapi-spec-validator +iniconfig==2.0.0 # via pytest -jsonschema==4.6.0 +jsonschema==4.17.3 # via + # jsonschema-spec # openapi-schema-validator # openapi-spec-validator -openapi-schema-validator==0.2.3 +jsonschema-spec==0.1.2 + # via openapi-spec-validator +lazy-object-proxy==1.9.0 # via openapi-spec-validator -openapi-spec-validator==0.4.0 +openapi-schema-validator==0.3.4 + # via openapi-spec-validator +openapi-spec-validator==0.5.1 # via -r requirements/tests.in -packaging==21.3 +packaging==22.0 # via pytest +pathable==0.4.3 + # via jsonschema-spec +pkgutil-resolve-name==1.3.10 + # via jsonschema pluggy==1.0.0 # via pytest py==1.11.0 # via pytest -pyparsing==3.0.9 - # via packaging -pyrsistent==0.18.1 +pyrsistent==0.19.3 # via jsonschema pytest==6.2.5 # via # -r requirements/tests.in # pytest-cov -pytest-cov==3.0.0 +pytest-cov==4.0.0 # via -r requirements/tests.in pyyaml==6.0 - # via openapi-spec-validator + # via + # jsonschema-spec + # openapi-spec-validator toml==0.10.2 # via pytest tomli==2.0.1 # via coverage -zipp==3.8.0 +typing-extensions==4.4.0 + # via jsonschema-spec +zipp==3.11.0 # via importlib-resources - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements/typing.txt b/requirements/typing.txt index 5126f10..a70f3f5 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -5,11 +5,11 @@ # # pip-compile-multi # -mypy==0.961 +mypy==0.991 # via -r requirements/typing.in mypy-extensions==0.4.3 # via mypy tomli==2.0.1 # via mypy -typing-extensions==4.2.0 +typing-extensions==4.4.0 # via mypy diff --git a/setup.cfg b/setup.cfg index 1cd48b1..dc5aa73 100644 --- a/setup.cfg +++ b/setup.cfg @@ -119,3 +119,4 @@ addopts = --cov=apiflask --cov-branch --cov-report=term-missing [coverage:run] omit = src/apiflask/_decorators.py + src/apiflask/views.py diff --git a/src/apiflask/__init__.py b/src/apiflask/__init__.py index 1ffb900..bb360b4 100644 --- a/src/apiflask/__init__.py +++ b/src/apiflask/__init__.py @@ -11,4 +11,4 @@ from .security import HTTPBasicAuth as HTTPBasicAuth from .security import HTTPTokenAuth as HTTPTokenAuth -__version__ = '1.2.0.dev' +__version__ = '1.2.1.dev' diff --git a/src/apiflask/app.py b/src/apiflask/app.py index ca8514c..a6fa4b9 100644 --- a/src/apiflask/app.py +++ b/src/apiflask/app.py @@ -10,13 +10,18 @@ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # pragma: no cover from apispec import APISpec +from apispec import BasePlugin from apispec.ext.marshmallow import MarshmallowPlugin from flask import Blueprint from flask import Flask from flask import jsonify from flask import render_template_string from flask.config import ConfigAttribute -from flask.globals import _request_ctx_stack +try: + from flask.globals import request_ctx # type: ignore +except ImportError: # pragma: no cover + from flask.globals import _request_ctx_stack + request_ctx = None # type: ignore from flask.wrappers import Response with warnings.catch_warnings(): @@ -44,6 +49,7 @@ from .types import OpenAPISchemaType from .openapi import add_response from .openapi import add_response_with_schema +from .openapi import default_bypassed_endpoints from .openapi import default_response from .openapi import get_tag from .openapi import get_operation_tags @@ -273,6 +279,7 @@ def __init__( openapi_blueprint_url_prefix: t.Optional[str] = None, json_errors: bool = True, enable_openapi: bool = True, + spec_plugins: t.Optional[t.List[BasePlugin]] = None, static_url_path: t.Optional[str] = None, static_folder: str = 'static', static_host: t.Optional[str] = None, @@ -306,9 +313,16 @@ def __init__( `docs_path`, etc.), defaults to `None`. json_errors: If `True`, APIFlask will return a JSON response for HTTP errors. enable_openapi: If `False`, will disable OpenAPI spec and API docs views. + spec_plugins: List of apispec-compatible plugins (subclasses of `apispec.BasePlugin`), + defaults to `None`. The `MarshmallowPlugin` for apispec is already included + by default, so it doesn't need to be provided here. Other keyword arguments are directly passed to `flask.Flask`. + *Version changed: 1.2.0* + + - Add `spec_plugins` parameter. + *Version changed: 1.1.0* - Add `docs_ui` parameter. @@ -348,6 +362,7 @@ def __init__( self.error_callback: ErrorCallbackType = self._error_handler self.schema_name_resolver = self._schema_name_resolver + self.spec_plugins: t.List[BasePlugin] = spec_plugins or [] self._spec: t.Optional[t.Union[dict, str]] = None self._auth_blueprints: t.Dict[str, t.Dict[str, t.Any]] = {} @@ -406,7 +421,7 @@ def get_pet(name, pet_id, age, query, pet): *Version added: 0.2.0* """ - req = _request_ctx_stack.top.request + req = request_ctx.request if request_ctx else _request_ctx_stack.top.request # type: ignore if req.routing_exception is not None: self.raise_routing_exception(req) rule = req.url_rule @@ -421,8 +436,9 @@ def get_pet(name, pet_id, age, query, pet): view_function = self.view_functions[rule.endpoint] if hasattr(self, 'ensure_sync'): # pragma: no cover view_function = self.ensure_sync(view_function) - if rule.endpoint == 'static': + if rule.endpoint == 'static' or hasattr(view_function, '_only_kwargs'): # app static route only accepts keyword arguments, see flask#3762 + # view classes created by Flask only accept keyword arguments return view_function(**req.view_args) # type: ignore else: return view_function(*req.view_args.values()) # type: ignore @@ -499,7 +515,7 @@ def my_error_processor(error): so you can get error information via it's attributes: - status_code: If the error is triggered by a validation error, the value will be - 400 (default) or the value you passed in config `VALIDATION_ERROR_STATUS_CODE`. + 422 (default) or the value you passed in config `VALIDATION_ERROR_STATUS_CODE`. If the error is triggered by [`HTTPError`][apiflask.exceptions.HTTPError] or [`abort`][apiflask.exceptions.abort], it will be the status code you passed. Otherwise, it will be the status code set by Werkzueg when @@ -847,13 +863,16 @@ def _generate_spec(self) -> APISpec: kwargs['externalDocs'] = self.external_docs ma_plugin: MarshmallowPlugin = MarshmallowPlugin( - schema_name_resolver=self.schema_name_resolver + schema_name_resolver=self.schema_name_resolver # type: ignore ) + + spec_plugins: t.List[BasePlugin] = [ma_plugin, *self.spec_plugins] + spec: APISpec = APISpec( title=self.title, version=self.version, openapi_version=self.config['OPENAPI_VERSION'], - plugins=[ma_plugin], + plugins=spec_plugins, info=self._make_info(), tags=self._make_tags(), **kwargs @@ -889,8 +908,7 @@ def _generate_spec(self) -> APISpec: operations: t.Dict[str, t.Any] = {} view_func: ViewFuncType = self.view_functions[rule.endpoint] # type: ignore # skip endpoints from openapi blueprint and the built-in static endpoint - if rule.endpoint.startswith('openapi') or \ - rule.endpoint.startswith('static'): + if rule.endpoint in default_bypassed_endpoints: continue blueprint_name: t.Optional[str] = None # type: ignore if '.' in rule.endpoint: @@ -900,7 +918,8 @@ def _generate_spec(self) -> APISpec: # just a normal view with dots in its endpoint, reset blueprint_name blueprint_name = None else: - if not hasattr(blueprint, 'enable_openapi') or \ + if rule.endpoint == (f'{blueprint_name}.static') or \ + not hasattr(blueprint, 'enable_openapi') or \ not blueprint.enable_openapi: # type: ignore continue # add a default 200 response for bare views @@ -1168,7 +1187,13 @@ def _generate_spec(self) -> APISpec: # parameters path_arguments: t.Iterable = re.findall(r'<(([^<:]+:)?([^>]+))>', rule.rule) - if path_arguments: + if ( + path_arguments + and not ( + hasattr(view_func, '_spec') + and view_func._spec.get('omit_default_path_parameters', False) + ) + ): arguments: t.List[t.Dict[str, str]] = [] for _, argument_type, argument_name in path_arguments: argument = get_argument(argument_type, argument_name) diff --git a/src/apiflask/fields.py b/src/apiflask/fields.py index 08d1939..f068e88 100644 --- a/src/apiflask/fields.py +++ b/src/apiflask/fields.py @@ -35,6 +35,7 @@ from marshmallow.fields import Tuple as Tuple from marshmallow.fields import URL as URL from marshmallow.fields import UUID as UUID +from marshmallow.fields import Enum as Enum from webargs.fields import DelimitedList as DelimitedList from webargs.fields import DelimitedTuple as DelimitedTuple diff --git a/src/apiflask/openapi.py b/src/apiflask/openapi.py index 4029a09..d994cf9 100644 --- a/src/apiflask/openapi.py +++ b/src/apiflask/openapi.py @@ -15,6 +15,15 @@ from .blueprint import APIBlueprint +default_bypassed_endpoints: t.List[str] = [ + 'static', + 'openapi.spec', + 'openapi.docs', + 'openapi.redoc', + 'openapi.swagger_ui_oauth_redirect', + '_debug_toolbar.static', # Flask-DebugToolbar +] + default_response = { 'schema': {}, 'status_code': 200, @@ -112,7 +121,7 @@ def get_security_and_security_schemes( """Make security and security schemes from given auth names and schemes.""" security: t.Dict[HTTPAuthType, str] = {} security_schemes: t.Dict[str, t.Dict[str, str]] = {} - for name, auth in zip(auth_names, auth_schemes): + for name, auth in zip(auth_names, auth_schemes): # noqa: B905 security[auth] = name security_schemes[name] = get_security_scheme(auth) if hasattr(auth, 'description') and auth.description is not None: diff --git a/src/apiflask/route.py b/src/apiflask/route.py index c365ff8..085f9cf 100644 --- a/src/apiflask/route.py +++ b/src/apiflask/route.py @@ -1,12 +1,14 @@ import typing as t -from flask.views import MethodView +from flask.views import View as FlaskView from .openapi import get_path_description from .openapi import get_path_summary from .types import ViewClassType from .types import ViewFuncOrClassType from .types import ViewFuncType +from .views import MethodView +from apiflask.views import MethodView as FlaskMethodView def route_patch(cls): @@ -34,6 +36,8 @@ def record_spec_for_view_class( view_func._method_spec = {} if not hasattr(view_func, '_spec'): view_func._spec = {} + if not view_class.methods: # no methods defined + return view_func for method_name in view_class.methods: # type: ignore # method_name: ['GET', 'POST', ...] method = view_class.__dict__[method_name.lower()] @@ -64,6 +68,7 @@ def add_url_rule( rule: str, endpoint: t.Optional[str] = None, view_func: t.Optional[ViewFuncOrClassType] = None, + provide_automatic_options: t.Optional[bool] = None, **options: t.Any, ): """Record the spec for view classes before calling the actual `add_url_rule` method. @@ -72,25 +77,40 @@ def add_url_rule( a view function created by `ViewClass.as_view()`. It only accepts a view class when using the route decorator on a view class. """ - view_class: ViewClassType - is_view_class: bool = False + if isinstance(view_func, type): + # call as_view() for MethodView passed with @route + if endpoint is None: + endpoint = view_func.__name__ + view_func = view_func.as_view(endpoint) # type: ignore if hasattr(view_func, 'view_class'): - # a function returned by MethodViewClass.as_view() - is_view_class = True + # view function created with MethodViewClass.as_view() view_class = view_func.view_class # type: ignore - elif isinstance(view_func, type(MethodView)): - # a MethodView class passed with the route decorator - is_view_class = True - view_class = view_func # type: ignore - if endpoint is None: - endpoint = view_class.__name__ - view_func = view_class.as_view(endpoint) + if not issubclass(view_class, MethodView): + # skip View-based class + view_func._spec = {'hide': True} # type: ignore + else: + # record spec for MethodView class + if hasattr(self, 'enable_openapi') and self.enable_openapi: + view_func = record_spec_for_view_class(view_func, view_class) # type: ignore + + # view func created by Flask's View only accpets keyword arguments + if issubclass(view_class, FlaskView): + view_func._only_kwargs = True # type: ignore - if is_view_class and hasattr(self, 'enable_openapi') and self.enable_openapi: - view_func = record_spec_for_view_class(view_func, view_class) # type: ignore + if issubclass(view_class, FlaskMethodView): + raise RuntimeError( + 'APIFlask only supports generating OpenAPI spec for view classes created ' + 'with apiflask.views.MethodView (`from apiflask.views import MethodView`).', + ) - super(cls, self).add_url_rule(rule, endpoint, view_func, **options) + super(cls, self).add_url_rule( + rule, + endpoint, + view_func, + provide_automatic_options=provide_automatic_options, + **options + ) cls.add_url_rule = add_url_rule return cls diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py index 27ac754..e7e4a45 100644 --- a/src/apiflask/scaffold.py +++ b/src/apiflask/scaffold.py @@ -7,7 +7,6 @@ from flask import current_app from flask import jsonify from flask import Response -from flask.views import MethodView from marshmallow import ValidationError as MarshmallowValidationError from webargs.flaskparser import FlaskParser as BaseFlaskParser from webargs.multidictproxy import MultiDictProxy @@ -23,6 +22,7 @@ from .types import RequestType from .types import ResponseReturnValueType from .types import SchemaType +from .views import MethodView BODY_LOCATIONS = ['json', 'files', 'form', 'form_and_files', 'json_or_form'] SUPPORTED_LOCATIONS = BODY_LOCATIONS + [ @@ -326,6 +326,8 @@ def decorator(f): else: if not hasattr(f, '_spec') or f._spec.get('args') is None: _annotate(f, args=[]) + if location == 'path': + _annotate(f, omit_default_path_parameters=True) # TODO: Support set example for request parameters f._spec['args'].append((schema, location)) return use_args(schema, location=location, **kwargs)(f) diff --git a/src/apiflask/schemas.py b/src/apiflask/schemas.py index 861ed05..aeb5c4e 100644 --- a/src/apiflask/schemas.py +++ b/src/apiflask/schemas.py @@ -1,8 +1,9 @@ import typing as t -from marshmallow import Schema as Schema +from marshmallow import Schema as BaseSchema from marshmallow.fields import Integer from marshmallow.fields import URL +from marshmallow.orderedset import OrderedSet # schema for the detail object of validation error response @@ -50,6 +51,19 @@ } +class Schema(BaseSchema): + """A base schema for all schemas. + + The different between marshmallow's `Schema` and APIFlask's `Schema` is that the latter + sets `set_class` to `OrderedSet` by default. + + *Version Added: 1.2.0* + """ + # use ordered set to keep the order of fields + # can be removed when https://github.com/marshmallow-code/marshmallow/pull/1896 is merged + set_class = OrderedSet + + class EmptySchema(Schema): """An empty schema used to generate a 204 response. diff --git a/src/apiflask/settings.py b/src/apiflask/settings.py index 2cab8a4..82054f9 100644 --- a/src/apiflask/settings.py +++ b/src/apiflask/settings.py @@ -38,7 +38,7 @@ NOT_FOUND_DESCRIPTION: str = 'Not found' VALIDATION_ERROR_DESCRIPTION: str = 'Validation error' AUTH_ERROR_DESCRIPTION: str = 'Authentication error' -VALIDATION_ERROR_STATUS_CODE: int = 400 +VALIDATION_ERROR_STATUS_CODE: int = 422 AUTH_ERROR_STATUS_CODE: int = 401 VALIDATION_ERROR_SCHEMA: OpenAPISchemaType = validation_error_schema HTTP_ERROR_SCHEMA: OpenAPISchemaType = http_error_schema @@ -67,3 +67,6 @@ RAPIDOC_CONFIG: t.Optional[dict] = None RAPIPDF_JS: str = 'https://unpkg.com/rapipdf/dist/rapipdf-min.js' RAPIPDF_CONFIG: t.Optional[dict] = None + +# Version changed: 1.2.0 +# Change VALIDATION_ERROR_STATUS_CODE from 400 to 422. diff --git a/src/apiflask/types.py b/src/apiflask/types.py index 8c156a5..492f560 100644 --- a/src/apiflask/types.py +++ b/src/apiflask/types.py @@ -8,7 +8,6 @@ if t.TYPE_CHECKING: # pragma: no cover from flask.wrappers import Response # noqa: F401 - from flask.views import View # noqa: F401 from werkzeug.datastructures import Headers # noqa: F401 from _typeshed.wsgi import WSGIApplication # noqa: F401 from .fields import Field # noqa: F401 @@ -16,29 +15,38 @@ from .security import HTTPBasicAuth # noqa: F401 from .security import HTTPTokenAuth # noqa: F401 from .exceptions import HTTPError # noqa: F401 + from .views import View # noqa: F401 DecoratedType = t.TypeVar('DecoratedType', bound=t.Callable[..., t.Any]) RequestType = t.TypeVar('RequestType') ResponseBodyType = t.Union[ - str, bytes, list, t.Dict[str, t.Any], t.Generator[str, None, None], 'Response' + str, + bytes, + t.List[t.Any], + # Only dict is actually accepted, but Mapping allows for TypedDict. + t.Mapping[str, t.Any], + t.Iterator[str], + t.Iterator[bytes], + 'Response', ] + ResponseStatusType = t.Union[str, int] _HeaderName = str _HeaderValue = t.Union[str, t.List[str], t.Tuple[str, ...]] ResponseHeaderType = t.Union[ t.Dict[_HeaderName, _HeaderValue], t.Mapping[_HeaderName, _HeaderValue], - t.List[t.Tuple[_HeaderName, _HeaderValue]], + t.Sequence[t.Tuple[_HeaderName, _HeaderValue]], 'Headers' ] ResponseReturnValueType = t.Union[ ResponseBodyType, - t.Tuple[ResponseBodyType, ResponseStatusType], t.Tuple[ResponseBodyType, ResponseHeaderType], + t.Tuple[ResponseBodyType, ResponseStatusType], t.Tuple[ResponseBodyType, ResponseStatusType, ResponseHeaderType], - 'WSGIApplication' + 'WSGIApplication', ] SpecCallbackType = t.Callable[[t.Union[dict, str]], t.Union[dict, str]] ErrorCallbackType = t.Callable[['HTTPError'], ResponseReturnValueType] @@ -51,6 +59,11 @@ ViewClassType = t.Type['View'] ViewFuncOrClassType = t.Union[t.Callable, ViewClassType] +RouteCallableType = t.Union[ + t.Callable[..., ResponseReturnValueType], + t.Callable[..., t.Awaitable[ResponseReturnValueType]], +] + class PaginationType(Protocol): page: int diff --git a/src/apiflask/views.py b/src/apiflask/views.py new file mode 100644 index 0000000..f85e26c --- /dev/null +++ b/src/apiflask/views.py @@ -0,0 +1,189 @@ +# From Flask, see the NOTICE file for the license details. +import typing as t + +from flask import current_app +from flask import request + +from .types import ResponseReturnValueType +from .types import RouteCallableType + + +http_method_funcs = frozenset( + ['get', 'post', 'head', 'options', 'delete', 'put', 'trace', 'patch'] +) + + +class View: + """Flask's `View` with some modifications for API support. + + Note: APIFlask only support generating OpenAPI spec for + `MethodView`-based classes. + + Set `methods` on the class to change what methods the view + accepts. + + Set `decorators` on the class to apply a list of decorators to + the generated view function. Decorators applied to the class itself + will not be applied to the generated view function! + + Set `init_every_request` to `False` for efficiency, unless + you need to store request-global data on `self`. + """ + + #: The methods this view is registered for. Uses the same default + #: (`['GET', 'HEAD', 'OPTIONS']`) as `route` and + #: `add_url_rule` by default. + methods: t.ClassVar[t.Optional[t.Collection[str]]] = None + + #: Control whether the `OPTIONS` method is handled automatically. + #: Uses the same default (`True`) as `route` and + #: `add_url_rule` by default. + provide_automatic_options: t.ClassVar[t.Optional[bool]] = None + + #: A list of decorators to apply, in order, to the generated view + #: function. Remember that `@decorator` syntax is applied bottom + #: to top, so the first decorator in the list would be the bottom + #: decorator. + #: + #: *Version added: Flask 0.8* + decorators: t.ClassVar[t.List[t.Callable]] = [] + + #: Create a new instance of this view class for every request by + #: default. If a view subclass sets this to `False`, the same + #: instance is used for every request. + #: + #: A single instance is more efficient, especially if complex setup + #: is done during init. However, storing data on `self` is no + #: longer safe across requests, and :data:`~flask.g` should be used + #: instead. + #: + #: *Version added: Flask 2.2* + init_every_request: t.ClassVar[bool] = True + + def dispatch_request(self) -> ResponseReturnValueType: + """The actual view function behavior. Subclasses must override + this and return a valid response. Any variables from the URL + rule are passed as keyword arguments. + """ + raise NotImplementedError() + + @classmethod + def as_view( + cls, name: str, *class_args: t.Any, **class_kwargs: t.Any + ) -> RouteCallableType: + """Convert the class into a view function that can be registered + for a route. + + By default, the generated view will create a new instance of the + view class for every request and call its + `dispatch_request` method. If the view class sets + `init_every_request` to `False`, the same instance will + be used for every request. + + The arguments passed to this method are forwarded to the view + class `__init__` method. + + *Version added: Flask 2.2* + Added the `init_every_request` class attribute. + """ + if cls.init_every_request: + + def view(*args: t.Any, **kwargs: t.Any) -> ResponseReturnValueType: + self = view.view_class( # type: ignore[attr-defined] + *class_args, **class_kwargs + ) + if hasattr(current_app, 'ensure_sync'): + return current_app.ensure_sync( # type: ignore + self.dispatch_request + )(*args, **kwargs) + return self.dispatch_request(*args, **kwargs) # type: ignore + + else: + self = cls(*class_args, **class_kwargs) + + def view(*args: t.Any, **kwargs: t.Any) -> ResponseReturnValueType: + if hasattr(current_app, 'ensure_sync'): + return current_app.ensure_sync( # type: ignore + self.dispatch_request + )(*args, **kwargs) + return self.dispatch_request(*args, **kwargs) + + if cls.decorators: + view.__name__ = name + view.__module__ = cls.__module__ + for decorator in cls.decorators: + view = decorator(view) + + # We attach the view class to the view function for two reasons: + # first of all it allows us to easily figure out what class-based + # view this thing came from, secondly it's also used for instantiating + # the view class so you can actually replace it with something else + # for testing purposes and debugging. + view.view_class = cls # type: ignore + view.__name__ = name + view.__doc__ = cls.__doc__ + view.__module__ = cls.__module__ + view.methods = cls.methods # type: ignore + view.provide_automatic_options = cls.provide_automatic_options # type: ignore + return view + + +class MethodView(View): + """Dispatches request methods to the corresponding instance methods. + For example, if you implement a `get` method, it will be used to + handle `GET` requests. + + This can be useful for defining a REST API. + + `methods` is automatically set based on the methods defined on + the class. + + Example: + + ```python + class CounterAPI(MethodView): + + @app.output(CounterSchema) + def get(self): + return str(session.get('counter', 0)) + + @app.input(CounterSchema) + def post(self): + session['counter'] = session.get('counter', 0) + 1 + return redirect(url_for('counter')) + + app.add_url_rule( + '/counter', view_func=CounterAPI.as_view('counter') + ) + ``` + """ + + def __init_subclass__(cls, **kwargs: t.Any) -> None: + super().__init_subclass__(**kwargs) + + if 'methods' not in cls.__dict__: + methods = set() + + for base in cls.__bases__: + if getattr(base, 'methods', None): + methods.update(base.methods) # type: ignore[attr-defined] + + for key in http_method_funcs: + if hasattr(cls, key): + methods.add(key.upper()) + + if methods: + cls.methods = methods + + def dispatch_request(self, *args: t.Any, **kwargs: t.Any) -> ResponseReturnValueType: + meth = getattr(self, request.method.lower(), None) + + # If the request method is HEAD and we don't have a handler for it + # retry with GET. + if meth is None and request.method == 'HEAD': + meth = getattr(self, 'get', None) + + assert meth is not None, f'Unimplemented method {request.method!r}' + if hasattr(current_app, 'ensure_sync'): + meth = current_app.ensure_sync(meth) + return meth(*args, **kwargs) # type: ignore diff --git a/tests/schemas.py b/tests/schemas.py index 045120d..dfe1ddb 100644 --- a/tests/schemas.py +++ b/tests/schemas.py @@ -5,6 +5,7 @@ from apiflask.fields import Integer from apiflask.fields import List from apiflask.fields import String +from apiflask.validators import OneOf class Foo(Schema): @@ -52,6 +53,10 @@ class FormAndFiles(Schema): image = File() +class EnumPathParameter(Schema): + image_type = String(validate=OneOf(['jpg', 'png', 'tiff', 'webp'])) + + class Pagination(Schema): class Meta: unknown = EXCLUDE diff --git a/tests/test_app.py b/tests/test_app.py index aa80981..ff7c0cc 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,6 @@ import pytest +from apispec import BasePlugin from flask import Blueprint -from flask.views import MethodView from openapi_spec_validator import validate_spec from .schemas import Bar @@ -11,6 +11,7 @@ from apiflask import Schema from apiflask.fields import Integer from apiflask.fields import String +from apiflask.views import MethodView def test_app_init(app): @@ -46,16 +47,16 @@ def test_json_errors_reuse_werkzeug_headers(app, client): def foo(): pass - rv = client.post('/foo') - assert rv.status_code == 405 - assert 'Allow' in rv.headers - # test manually raise 405 @app.get('/bar') def bar(): from werkzeug.exceptions import MethodNotAllowed raise MethodNotAllowed(valid_methods=['GET']) + rv = client.post('/foo') + assert rv.status_code == 405 + assert 'Allow' in rv.headers + rv = client.get('/bar') assert rv.status_code == 405 assert rv.headers['Content-Type'] == 'application/json' @@ -189,28 +190,29 @@ def get(self): def test_dispatch_static_request(app, client): - # keyword arguments - rv = client.get('/static/hello.css') # endpoint: static - assert rv.status_code == 404 - # positional arguments @app.get('/mystatic/') @app.input(Foo) def mystatic(pet_id, foo): # endpoint: mystatic return {'pet_id': pet_id, 'foo': foo} + # positional arguments + # blueprint static route accepts both keyword/positional arguments + bp = APIBlueprint('foo', __name__, static_folder='static') + app.register_blueprint(bp, url_prefix='/foo') + rv = client.get('/mystatic/2', json={'id': 1, 'name': 'foo'}) assert rv.status_code == 200 assert rv.json['pet_id'] == 2 assert rv.json['foo'] == {'id': 1, 'name': 'foo'} - # positional arguments - # blueprint static route accepts both keyword/positional arguments - bp = APIBlueprint('foo', __name__, static_folder='static') - app.register_blueprint(bp, url_prefix='/foo') rv = client.get('/foo/static/hello.css') # endpoint: foo.static assert rv.status_code == 404 + # keyword arguments + rv = client.get('/static/hello.css') # endpoint: static + assert rv.status_code == 404 + def schema_name_resolver1(schema): name = schema.__class__.__name__ @@ -295,3 +297,19 @@ def multi_values(): assert rv.status_code == 201 assert rv.headers['Content-Type'] == 'application/json' assert rv.json == test_list + + +def test_apispec_plugins(app): + class TestPlugin(BasePlugin): + def operation_helper(self, path=None, operations=None, **kwargs) -> None: + operations.update({'post': 'some_injected_test_data'}) + + app.spec_plugins = [TestPlugin()] + + @app.get('/plugin_test') + def single_value(): + return 'plugin_test' + + spec = app._get_spec('json') + + assert spec['paths']['/plugin_test'].get('post') == 'some_injected_test_data' diff --git a/tests/test_base_response.py b/tests/test_base_response.py index 1adb4ec..6a4e6a3 100644 --- a/tests/test_base_response.py +++ b/tests/test_base_response.py @@ -117,10 +117,7 @@ def foo(): assert schema['$ref'] == schema_ref -# TODO pytest seems can't catch the ValueError happened in the output decorator def test_base_response_data_key(app, client): - pytest.skip() - app.config['BASE_RESPONSE_SCHEMA'] = BaseResponse app.config['BASE_RESPONSE_DATA_KEY '] = 'data' @@ -130,8 +127,9 @@ def foo(): data = {'id': '123', 'name': 'test'} return {'message': 'Success.', 'status_code': '200', 'info': data} - with pytest.raises(ValueError): - client.get('/') + with app.test_request_context('/'): + with pytest.raises(RuntimeError, match=r'The data key.*is not found in the returned dict.'): + foo() def test_base_response_204(app, client): diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py index 6b0cd9c..f2dfe3f 100644 --- a/tests/test_blueprint.py +++ b/tests/test_blueprint.py @@ -1,9 +1,9 @@ -from flask.views import MethodView from openapi_spec_validator import validate_spec from apiflask import APIBlueprint from apiflask.security import HTTPBasicAuth from apiflask.security import HTTPTokenAuth +from apiflask.views import MethodView def test_blueprint_object(): diff --git a/tests/test_commands.py b/tests/test_commands.py index d12f7e9..c725913 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2,6 +2,7 @@ import pytest +from .schemas import Foo from apiflask.commands import spec_command @@ -19,6 +20,21 @@ def test_flask_spec_output(app, cli_runner, tmp_path): assert json.loads(f.read()) == app.spec +def test_flask_spec_fields_order(app, cli_runner): + @app.get('/foo') + @app.output(Foo) + def foo(): + pass + + result = cli_runner.invoke(spec_command) + assert 'openapi' in result.output + assert json.loads(result.output) == app.spec + assert ( + list(json.loads(result.output)['components']['schemas']['Foo']['properties'].keys()) + == ['id', 'name'] + ) + + @pytest.mark.parametrize('format', ['json', 'yaml', 'yml', 'foo']) def test_flask_spec_format(app, cli_runner, format, tmp_path): local_spec_path = tmp_path / 'openapi.json' diff --git a/tests/test_decorator_auth_required.py b/tests/test_decorator_auth_required.py index d5ba5ad..886b50d 100644 --- a/tests/test_decorator_auth_required.py +++ b/tests/test_decorator_auth_required.py @@ -1,9 +1,9 @@ -from flask.views import MethodView from openapi_spec_validator import validate_spec from apiflask import APIBlueprint from apiflask.security import HTTPBasicAuth from apiflask.security import HTTPTokenAuth +from apiflask.views import MethodView def test_auth_required(app, client): diff --git a/tests/test_decorator_doc.py b/tests/test_decorator_doc.py index 9fb435e..5ed71b0 100644 --- a/tests/test_decorator_doc.py +++ b/tests/test_decorator_doc.py @@ -1,8 +1,8 @@ import pytest -from flask.views import MethodView from openapi_spec_validator import validate_spec from .schemas import Foo +from apiflask.views import MethodView def test_doc_summary_and_description(app, client): @@ -194,7 +194,7 @@ def bar(): assert rv.json['paths']['/bar']['get']['responses'][ '200']['description'] == 'Successful response' assert rv.json['paths']['/bar']['get']['responses'][ - '400']['description'] == 'Validation error' + '400']['description'] == 'Bad Request' assert '404' in rv.json['paths']['/foo']['get']['responses'] assert rv.json['paths']['/bar']['get']['responses'][ '404']['description'] == 'Not Found' @@ -242,7 +242,7 @@ def get(self): assert rv.json['paths']['/bar']['get']['responses'][ '200']['description'] == 'Successful response' assert rv.json['paths']['/bar']['get']['responses'][ - '400']['description'] == 'Validation error' + '400']['description'] == 'Bad Request' assert '404' in rv.json['paths']['/foo']['get']['responses'] assert rv.json['paths']['/bar']['get']['responses'][ '404']['description'] == 'Not Found' diff --git a/tests/test_decorator_input.py b/tests/test_decorator_input.py index 64cbb3e..62663d0 100644 --- a/tests/test_decorator_input.py +++ b/tests/test_decorator_input.py @@ -1,17 +1,18 @@ import io import pytest -from flask.views import MethodView from openapi_spec_validator import validate_spec from werkzeug.datastructures import FileStorage from .schemas import Bar +from .schemas import EnumPathParameter from .schemas import Files from .schemas import Foo from .schemas import Form from .schemas import FormAndFiles from .schemas import Query from apiflask.fields import String +from apiflask.views import MethodView def test_input(app, client): @@ -28,7 +29,7 @@ def post(self, data): for rule in ['/foo', '/bar']: rv = client.post(rule) - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json == { 'detail': { 'json': {'name': ['Missing data for required field.']} @@ -37,7 +38,7 @@ def post(self, data): } rv = client.post(rule, json={'id': 1}) - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json == { 'detail': { 'json': {'name': ['Missing data for required field.']} @@ -68,7 +69,7 @@ def foo(schema, schema2): return {'name': schema['name'], 'name2': schema2['name2']} rv = client.post('/foo') - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json == { 'detail': { 'query': {'name': ['Missing data for required field.']} @@ -77,7 +78,7 @@ def foo(schema, schema2): } rv = client.post('/foo?id=1&name=bar') - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json == { 'detail': { 'query': {'name2': ['Missing data for required field.']} @@ -181,6 +182,38 @@ def index(form_data): assert rv.json == {'name': True, 'image': True} +def test_input_with_path_location(app, client): + @app.get('/') + @app.input(EnumPathParameter, location='path') + def index(image_type, _): + return {'image_type': image_type} + + rv = client.get('/openapi.json') + assert rv.status_code == 200 + # TODO: Failed validating 'oneOf' in schema + # https://github.com/p1c2u/openapi-spec-validator/issues/113 + # validate_spec(rv.json) + assert '/{image_type}' in rv.json['paths'] + assert len(rv.json['paths']['/{image_type}']['get']['parameters']) == 1 + assert rv.json['paths']['/{image_type}']['get']['parameters'][0]['in'] == 'path' + assert rv.json['paths']['/{image_type}']['get']['parameters'][0]['name'] == 'image_type' + assert rv.json['paths']['/{image_type}']['get']['parameters'][0]['schema'] == { + 'type': 'string', + 'enum': ['jpg', 'png', 'tiff', 'webp'], + } + + rv = client.get('/png') + assert rv.status_code == 200 + assert rv.json == {'image_type': 'png'} + + rv = client.get('/gif') + assert rv.status_code == 422 + assert rv.json['message'] == 'Validation error' + assert 'path' in rv.json['detail'] + assert 'image_type' in rv.json['detail']['path'] + assert rv.json['detail']['path']['image_type'] == ['Must be one of: jpg, png, tiff, webp.'] + + @pytest.mark.parametrize('locations', [ ['files', 'form'], ['files', 'json'], @@ -236,7 +269,7 @@ def spam(body): return body rv = client.get('/foo') - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json == { 'detail': { 'query': {'name': ['Missing data for required field.']} @@ -249,7 +282,7 @@ def spam(body): assert rv.json == {'name': 'grey'} rv = client.post('/bar') - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json == { 'detail': { 'json': {'name': ['Missing data for required field.']} diff --git a/tests/test_decorator_output.py b/tests/test_decorator_output.py index 178a731..d45ea66 100644 --- a/tests/test_decorator_output.py +++ b/tests/test_decorator_output.py @@ -1,10 +1,10 @@ from flask import make_response -from flask.views import MethodView from openapi_spec_validator import validate_spec from .schemas import Foo from .schemas import Query from apiflask.fields import String +from apiflask.views import MethodView def test_output(app, client): diff --git a/tests/test_fields.py b/tests/test_fields.py index 50e3712..dff513b 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -38,7 +38,7 @@ def index(files_data): }, content_type='multipart/form-data' ) - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json['detail']['files']['image'] == ['Not a valid file.'] @@ -77,5 +77,5 @@ def index(files_list_data): }, content_type='multipart/form-data' ) - assert rv.status_code == 400 + assert rv.status_code == 422 assert rv.json['detail']['files']['images']['2'] == ['Not a valid file.'] diff --git a/tests/test_openapi_basic.py b/tests/test_openapi_basic.py index 34e7e11..37fde53 100644 --- a/tests/test_openapi_basic.py +++ b/tests/test_openapi_basic.py @@ -6,6 +6,7 @@ from .schemas import Bar from .schemas import Baz from .schemas import Foo +from apiflask import APIBlueprint from apiflask import Schema as BaseSchema from apiflask.fields import Integer @@ -57,6 +58,20 @@ def foo(): assert '/foo' in new_spec['paths'] +def test_spec_bypass_endpoints(app): + + bp = APIBlueprint('foo', __name__, static_folder='static', url_prefix='/foo') + app.register_blueprint(bp) + + spec = app._get_spec() + assert '/static' not in spec['paths'] + assert '/foo/static' not in spec['paths'] + assert '/docs' not in spec['paths'] + assert '/openapi.json' not in spec['paths'] + assert '/redoc' not in spec['paths'] + assert '/docs/oauth2-redirect' not in spec['paths'] + + def test_spec_attribute(app): spec = app._get_spec() diff --git a/tests/test_route.py b/tests/test_route.py index fb2ee4f..5159dc3 100644 --- a/tests/test_route.py +++ b/tests/test_route.py @@ -1,10 +1,13 @@ import pytest -from flask.views import MethodView +from flask.views import View as FlaskView from openapi_spec_validator import validate_spec from .schemas import Foo from apiflask import APIBlueprint from apiflask import HTTPTokenAuth +from apiflask.views import MethodView +from apiflask.views import MethodView as FlaskMethodView +from apiflask.views import View @pytest.mark.parametrize('method', ['get', 'post', 'put', 'patch', 'delete']) @@ -164,7 +167,7 @@ def post(self): """Create foo""" return 'post' - app.add_url_rule('/', view_func=Foo, methods=['GET', 'POST']) + app.add_url_rule('/', view_func=Foo.as_view('foo'), methods=['GET', 'POST']) rv = client.get('/') assert rv.data == b'get' @@ -212,3 +215,37 @@ def foo(): assert rv.status_code == 200 validate_spec(rv.json) assert rv.json['paths']['/']['get'] + + +@pytest.mark.parametrize('view_class', [View, FlaskView]) +def test_add_url_rule_skip_collecting_spec_from_view_class(app, client, view_class): + class Foo(view_class): + def dispatch_request(self): + return 'Hello' + + app.add_url_rule('/foo', view_func=Foo.as_view('foo')) + + rv = client.get('/openapi.json') + assert rv.status_code == 200 + assert '/foo' not in rv.json['paths'] + + rv = client.get('/foo') + assert rv.status_code == 200 + + +def test_runtime_error_on_flask_methodview_class(app): + with pytest.raises(RuntimeError): + class Foo(FlaskMethodView): + pass + + app.add_url_rule('/foo', view_func=Foo.as_view('foo')) + + +def test_empty_methodview_class(app, client): + class Foo(MethodView): + pass + + app.add_url_rule('/foo', view_func=Foo.as_view('foo')) + rv = client.get('/openapi.json') + assert rv.status_code == 200 + assert '/foo' not in rv.json['paths'] diff --git a/tests/test_settings_auto_behaviour.py b/tests/test_settings_auto_behaviour.py index 90e6f73..bc1ab62 100644 --- a/tests/test_settings_auto_behaviour.py +++ b/tests/test_settings_auto_behaviour.py @@ -1,11 +1,11 @@ import pytest -from flask.views import MethodView from openapi_spec_validator import validate_spec from .schemas import Foo from .schemas import Query from apiflask import APIBlueprint from apiflask.security import HTTPBasicAuth +from apiflask.views import MethodView def test_auto_tags(app, client): @@ -238,11 +238,11 @@ def foo(): rv = client.get('/openapi.json') assert rv.status_code == 200 validate_spec(rv.json) - assert bool('400' in rv.json['paths']['/foo']['post']['responses']) is config_value + assert bool('422' in rv.json['paths']['/foo']['post']['responses']) is config_value if config_value: assert 'ValidationError' in rv.json['components']['schemas'] assert '#/components/schemas/ValidationError' in \ - rv.json['paths']['/foo']['post']['responses']['400'][ + rv.json['paths']['/foo']['post']['responses']['422'][ 'content']['application/json']['schema']['$ref'] diff --git a/tests/test_settings_response_customization.py b/tests/test_settings_response_customization.py index c085dce..2737e48 100644 --- a/tests/test_settings_response_customization.py +++ b/tests/test_settings_response_customization.py @@ -53,7 +53,7 @@ def eggs(): def test_validation_error_status_code_and_description(app, client): - app.config['VALIDATION_ERROR_STATUS_CODE'] = 422 + app.config['VALIDATION_ERROR_STATUS_CODE'] = 400 app.config['VALIDATION_ERROR_DESCRIPTION'] = 'Bad' @app.post('/foo') @@ -64,9 +64,9 @@ def foo(): rv = client.get('/openapi.json') assert rv.status_code == 200 validate_spec(rv.json) - assert rv.json['paths']['/foo']['post']['responses']['422'] is not None + assert rv.json['paths']['/foo']['post']['responses']['400'] is not None assert rv.json['paths']['/foo']['post']['responses'][ - '422']['description'] == 'Bad' + '400']['description'] == 'Bad' @pytest.mark.parametrize('schema', [ @@ -84,8 +84,8 @@ def foo(): rv = client.get('/openapi.json') assert rv.status_code == 200 validate_spec(rv.json) - assert rv.json['paths']['/foo']['post']['responses']['400'] - assert rv.json['paths']['/foo']['post']['responses']['400'][ + assert rv.json['paths']['/foo']['post']['responses']['422'] + assert rv.json['paths']['/foo']['post']['responses']['422'][ 'description'] == 'Validation error' assert 'ValidationError' in rv.json['components']['schemas']