Skip to main content
友田 陽大
Flask in production
Python
Flask
OpenAPI
Swagger
REST API
marshmallow
アーキテクチャ設計

Auto-Generating OpenAPI/Swagger in Flask: Building Schema-Driven REST APIs and API Docs at Production Quality with Flask-smorest

An implementation guide to auto-generating OpenAPI/Swagger with Flask-smorest 0.47. Bundle Flask + marshmallow + webargs + apispec to simultaneously generate input validation, response shaping, and the OpenAPI spec from a single schema. @blp.arguments/@blp.response, Swagger UI/ReDoc, pagination, error documentation, protecting Swagger UI in production, and generating openapi.json in CI — explained with real code.

Published
Reading time
23 min read
Author
友田 陽大
Share
Contents

Introduction: Your Flask API Has No "Contract"

Picture handing a hand-written Flask REST API to another team (front end, mobile, an external partner). The first thing they ask is always this: "What's this endpoint's request body? What shape is the response? What comes back on an error?"

And where is the answer? In many Flask projects, the answer is reading the view-function code, asking on Slack, or sharing a Postman collection — all "substance-less documentation" maintained by hand, separate from the code. The code changes but the docs don't. Eventually the two diverge, and the docs become "lying documentation." This is technical debt itself.

The essence of the problem is that a hand-written Flask API has no machine-readable contract. Even if you carefully do Flask REST API design (MethodView / Blueprint / versioning), that design exists only in code humans read, not in a spec (OpenAPI doc) the API's consumers can reference mechanically.

Here, many recall FastAPI. As touched on in Flask vs FastAPI vs Django, FastAPI generates the OpenAPI spec and Swagger UI "for free" from type hints. This is FastAPI's decisive appeal, and the representative example of what Flask is considered "not to have."

This article's claim is simple. That gap is closed if you use Flask-smorest on the Flask side. Staying WSGI / Flask, auto-generate the OpenAPI spec, Swagger UI, and ReDoc from a single marshmallow schema, with that same schema also handling input validation and response shaping — what FastAPI gets with types, Flask gets with a marshmallow schema.

The author designed, implemented, and ran in production the backend of a B2B SaaS that won the Minister of Economy, Trade and Industry Award in Python / Flask / SQLAlchemy / PostgreSQL. Providing a "machine-readable contract" to the in-house front-end team, and to external partners hitting the API, supported both development speed and trust. This article systematizes the "documentation automation" design that was needed in that field experience, with real code faithful to the Flask-smorest official documentation.

💡 The version covered in this article: it assumes Flask-smorest 0.47.0. Flask-smorest is "a REST API framework based on Flask / Marshmallow," with dependencies flask>=3.0.2,<4 / marshmallow>=3.24.1,<5 / webargs>=8 / apispec[marshmallow]>=6, requiring Python 3.10 or above. This article's code is based on the official documentation's patterns. The design of marshmallow schemas themselves is treated as prerequisite knowledge in the marshmallow practical guide.


1. What Is Flask-smorest: A "Schema-Driven Core" Bundling 4 Libraries

Flask-smorest is not an API framework rebuilt from scratch. It's a thin adhesive layer that bundles 4 already-mature libraries and "drives them with a single schema."

ComponentRoleIts positioning in smorest
FlaskWSGI application / routingThe foundation. Loaded as an extension with Api(app)
marshmallowValidation / serialization via schemasThe contract's single source of truth (input, output, and spec)
webargsRequest parsing (query/json/path…)The substance of @blp.arguments
apispecConverting marshmallow schemas → the OpenAPI specThe engine of doc auto-generation

What this composition means is that a single marshmallow schema does 3 jobs at once.

  1. Validating the input boundary (load via webargs): validate the JSON / query that came from the client, and auto-return 422 if invalid
  2. Shaping the output boundary (dump): shape the return value with the schema, preventing the leakage of internal attributes
  3. Generating the OpenAPI spec (via apispec): generate the request-body / response JSON Schema from the same schema and display it in Swagger UI

In the marshmallow × Flask × SQLAlchemy guide, I argued that "a single schema protects the 2 boundaries of entrance and exit." Flask-smorest adds a 3rd boundary there — machine-readable documentation — at zero additional cost. This is the apex of DRY. Rewrite the schema, and validation, shaping, and documentation follow simultaneously, so there's no gap to diverge in the first place.

💡 A shift in thinking: FastAPI makes "Pydantic type hints" the single source of truth to generate OpenAPI. Flask-smorest makes "the marshmallow schema" the single source of truth to do the same thing. Only the means (type hints vs schema objects) differs; the value obtained is isomorphic. For a Flask project that has already invested in marshmallow, you can divert that investment to OpenAPI docs without migrating to ASGI.


2. Reading the Quickstart Closely: 4 New Concepts

The official quickstart is short, but the elements for understanding Flask-smorest are condensed into it. First look at the whole, then dissect it line by line.

from flask import Flask
from flask.views import MethodView
import marshmallow as ma
from flask_smorest import Api, Blueprint, abort
from .model import Pet

app = Flask(__name__)
app.config["API_TITLE"] = "My API"
app.config["API_VERSION"] = "v1"
app.config["OPENAPI_VERSION"] = "3.0.2"
api = Api(app)


class PetSchema(ma.Schema):
    id = ma.fields.Int(dump_only=True)
    name = ma.fields.String()


class PetQueryArgsSchema(ma.Schema):
    name = ma.fields.String()


blp = Blueprint("pets", "pets", url_prefix="/pets", description="Operations on pets")


@blp.route("/")
class Pets(MethodView):
    @blp.arguments(PetQueryArgsSchema, location="query")
    @blp.response(200, PetSchema(many=True))
    def get(self, args):
        """List pets"""
        return Pet.get(filters=args)

    @blp.arguments(PetSchema)
    @blp.response(201, PetSchema)
    def post(self, new_data):
        """Add a new pet"""
        item = Pet.create(**new_data)
        return item


api.register_blueprint(blp)

Here, 4 concepts specific to Flask-smorest appear. Let me look at them in order.

2.1 Api(app): Loading It as an Extension

With api = Api(app), you attach Flask-smorest to the app as an extension. This Api object is the command tower that holds apispec internally, scans the registered Blueprints to assemble the OpenAPI spec, and serves the Swagger UI / ReDoc endpoints. To combine with an application factory, you can use api.init_app(app) per the init_app pattern covered in the Flask production-operations guide.

⚠️ The 3 of API_TITLE / API_VERSION / OPENAPI_VERSION are mandatory. Without them, Api(app) fails at startup. API_VERSION (your API's version, e.g. v1) and OPENAPI_VERSION (the version of the OpenAPI spec itself, e.g. 3.0.2) are different things, so don't conflate them.

2.2 smorest's Blueprint: Not Flask's Standard Blueprint

The first pitfall is here.

from flask_smorest import Api, Blueprint, abort

This Blueprint is not flask.Blueprint but Flask-smorest's own Blueprint. It inherits Flask's standard Blueprint and additionally has OpenAPI-aware decorators (@blp.arguments / @blp.response / @blp.paginate …). Its constructor takes description=, and this becomes the OpenAPI tag description.

Similarly, abort is not flask.abort but smorest's enhanced abort. You can put an error message and additional info into the JSON error response (detailed in §5).

⚠️ Anti-pattern: mixing from flask import Blueprint, abort and from flask_smorest import Blueprint, abort. Use the former and @blp.arguments doesn't exist, giving an AttributeError. In files using Flask-smorest, import Blueprint / abort always from flask_smorest.

2.3 @blp.route("/") Decorates a Class

@blp.route("/")
class Pets(MethodView):
    def get(self, args): ...
    def post(self, new_data): ...

Flask-smorest treats class-based views (MethodView) as first-class. @blp.route("/") decorates the whole class, and the get / post methods within the class map to HTTP GET / POST respectively. MethodView is a Flask-core class (flask.views.MethodView), not smorest's own.

Because you can bundle multiple HTTP methods for one URL into one class, it naturally meshes with resource-oriented REST design. The design philosophy of MethodView itself (resource = class, method = HTTP verb) is covered in detail in the Flask REST API design guide. This article layers "documentation automation" onto that design.

2.4 The Order of Decorators and the Docstring

Notice the 2 decorators on the method, and the docstring below them.

@blp.arguments(PetSchema)          # 入力:検証して引数に注入
@blp.response(201, PetSchema)      # 出力:シリアライズしてステータス設定
def post(self, new_data):
    """Add a new pet"""            # ← OpenAPI の summary になる
    item = Pet.create(**new_data)
    return item
  • @blp.arguments(PetSchema): validate the request body with PetSchema, and inject the validated dict as the new_data argument (§3)
  • @blp.response(201, PetSchema): serialize the return value item with PetSchema, and set HTTP 201 (§4)
  • """Add a new pet""": the docstring becomes the OpenAPI operation summary. The source of the description shown in Swagger UI

With these 3 in place, POST /pets is automatically loaded into the docs as a machine-readable spec: "the request body is PetSchema, on success returns 201 with PetSchema, the description is Add a new pet." That writing the view itself is writing the documentation is the core of Flask-smorest.


3. @blp.arguments: Validating and Injecting the Input Boundary

@blp.arguments is the protagonist of Flask-smorest's entrance side. It does 2 things.

  1. Take the request data from the specified location and validate (load) it with the schema
  2. Inject the validated data into the view function as an argument

If validation fails, Flask-smorest auto-returns 422 Unprocessable Entity. You don't need to write try/except ValidationError inside the view function — boundary validation is declaratively externalized.

3.1 location: Where to Read From

The location of @blp.arguments(Schema, location=...) specifies where the data is taken from.

locationSourceUse
json (default)The request body's JSONPOST / PUT payloads
queryThe query stringList filtering / search parameters
pathURL path parametersResource IDs
formForm dataHTML form submissions
headersRequest headersValidating custom headers
cookiesCookies
filesUploaded filesMultipart
json_or_formJSON or formEndpoints supporting both

Omit location and json is the default. When you want to validate query parameters, explicitly state location="query", like the quickstart's GET.

3.2 How It's Injected: Positional Args / Keyword Args / Stacking

By default, @blp.arguments injects the validated data as a single positional argument (a dict).

@blp.arguments(PetSchema)
def post(self, new_data):   # new_data は検証済み dict
    ...

Pass as_kwargs=True and it's injected expanded as **kwargs rather than a dict.

@blp.arguments(PetSchema, as_kwargs=True)
def post(self, name, **kwargs):   # スキーマのフィールドが個別のキーワード引数に
    ...

Stacking multiple @blp.arguments injects multiple positional arguments in declaration order. This is effective when you want to validate query and json simultaneously.

@blp.arguments(PetQueryArgsSchema, location="query")
@blp.arguments(PetSchema, location="json")
def post(self, query_args, body):
    # query_args = query から、body = JSON から(装飾子の順に対応)
    ...

💡 The argument order of stacking corresponds to the decorator order "top to bottom." For readability, when the locations differ, give variable names that make the source clear, like query_args / body, so a later reader doesn't get lost.

3.3 422 Is "Auto-Documented"

This is the decisive difference from a hand-written API. An endpoint with @blp.arguments has a validation-error (422) response automatically added to the OpenAPI spec. That is, the API's consumer can know mechanically, just by looking at Swagger UI, that "send invalid input and 422 comes back, and the error's shape is this." Hand-written, the existence and shape of this 422 was "tacit knowledge you can't know without reading the code." Flask-smorest turns it into an explicit contract.


4. @blp.response: Shaping the Output Boundary and Setting the Status

@blp.response(status_code, Schema) is the protagonist of the exit side. It does 3 things.

  1. dump (serialize) the view function's return value with the schema
  2. Set the HTTP status code
  3. Register that response (status + schema) in the OpenAPI spec
@blp.response(200, PetSchema(many=True))
def get(self, args):
    return Pet.get(filters=args)   # ORMオブジェクトのリスト → PetSchema で整形

When returning a list, use Schema(many=True). many=True is reflected in the OpenAPI spec too, correctly documenting "the response is an array."

The power of dump_only fields (e.g. id = ma.fields.Int(dump_only=True)) also works here. id is not accepted on input (@blp.arguments) — preventing mass assignment — and is returned on output (@blp.response); you can declare a "read-only attribute" with one schema. This input/output-asymmetric design is as detailed in the marshmallow serialization/validation guide, and smorest translates it directly to OpenAPI's readOnly.

⚠️ An important exception (official caution): if the view function returns a werkzeug.BaseResponse (= a Response object or the result of make_response()), that Response is returned as-is, and neither schema dump nor status-code application happens. For cases where you build a Response directly, like a file download or a redirect, understand that @blp.response's schema shaping doesn't take effect. If you want schema shaping, the iron rule is to return a dict or an ORM object (don't make a Response).


5. Pagination and Errors: Turning Lists and Abnormal Cases into "Documented Contracts"

What most tends to cause "spec divergence" in a practical REST API is lists (pagination) and abnormal cases (errors). Flask-smorest has mechanisms to fix both as contracts.

5.1 @blp.paginate(): Declaring Pagination

@blp.route("/")
class Pets(MethodView):
    @blp.response(200, PetSchema(many=True))
    @blp.paginate()
    def get(self, pagination_parameters):
        pagination_parameters.item_count = Pet.size
        return Pet.get_elements(
            first_item=pagination_parameters.first_item,
            last_item=pagination_parameters.last_item,
        )

@blp.paginate() injects a PaginationParameters object into the view. What you get from it, and what you should do, is as follows.

  • Injected: pagination_parameters (.page / .page_size, the computed .first_item / .last_item)
  • You set: pagination_parameters.item_count = <total count> (needed to compute the total page count)
  • Auto-attached: the pagination metadata rides in the X-Pagination response header

The default parameters are DEFAULT_PAGINATION_PARAMETERS = {"page": 1, "page_size": 10, "max_page_size": 100}. The page / page_size query parameters and their defaults / upper bound (max_page_size) are also auto-documented in the OpenAPI spec. The client, just by looking at Swagger UI, knows mechanically "hit it with ?page=2&page_size=50, with an upper bound of 100."

💡 Putting pagination info in the X-Pagination header rather than the body is a design judgment. The response body is purified to "an array of resources," and the metadata (total count, whether there's a next page) is separated into the header. It's easier for the client to parse the metadata, and the body's schema isn't polluted by meta.

5.2 abort(): Documented Consistent Errors

Return abnormal cases with smorest's abort.

from flask_smorest import abort


@blp.route("/<int:pet_id>")
class PetById(MethodView):
    @blp.response(200, PetSchema)
    def get(self, pet_id):
        pet = Pet.get_by_id(pet_id)
        if pet is None:
            abort(404, message="Pet not found")
        return pet

smorest's abort extends flask.abort and can include additional info like message in the JSON error response. Flask-smorest shapes the error response into a consistent form (code / status / message / errors on validation errors), and moreover registers that form in the OpenAPI spec. That is, "this endpoint can return 404, and the error's shape is this" rides in the contract.

This "documented consistent error envelope," done by hand, will surely sprawl. The "unified JSON errors across all endpoints" argued in the Flask error-handling / observability guide, smorest provides as standard, and documented to boot.

Abnormal caseHand-written FlaskFlask-smorest
Validation error (422)Hand-write try/except everywhere@blp.arguments auto 422 + documentation
Not Found (404)Scatter jsonify(...), 404Consistent + documented with abort(404, message=...)
Error shapeDifferent per endpointUnified envelope + registered in OpenAPI
Conveying to consumersRead the code / verballyMachine-readable in Swagger UI

6. OpenAPI Settings and UI Serving: Enabling Swagger UI / ReDoc

So far you've understood that "the spec is auto-generated." Next is where and how to serve that spec. Flask-smorest can serve the OpenAPI JSON, Swagger UI, and ReDoc with config alone.

# OpenAPI 配信の設定(公式の既定値つき)
OPENAPI_VERSION = "3.0.2"                       # 必須
OPENAPI_URL_PREFIX = "/"                        # 既定 None → 設定しないと仕様を配信しない
OPENAPI_JSON_PATH = "openapi.json"              # 既定。OpenAPI JSON の配信パス
OPENAPI_SWAGGER_UI_PATH = "/swagger-ui"         # 既定 None → 設定すると Swagger UI 有効
OPENAPI_SWAGGER_UI_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/"
OPENAPI_REDOC_PATH = "/redoc"                   # 既定 None → 設定すると ReDoc 有効
OPENAPI_REDOC_URL = "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"

There are 3 default-value behaviors to pin down.

  1. OPENAPI_URL_PREFIX's default is None. Unless you set this, the OpenAPI spec itself isn't served. Only after setting it to "/" or the like does /openapi.json become visible.
  2. OPENAPI_SWAGGER_UI_PATH's default is also None. Only after setting it does Swagger UI become enabled. In the example above, you can see the interactive UI at /swagger-ui.
  3. OPENAPI_REDOC_PATH's default is also None. Only after setting it does ReDoc become enabled. ReDoc is read-only, easy-to-read three-column documentation.

💡 When to use Swagger UI vs ReDoc: Swagger UI is "hittable" (with Try it out you can send real requests from the browser), so it's powerful in the development / verification phase. ReDoc is "readable" (beautiful static docs), so it suits external publication / reference use. The standard is to enable both and use them as appropriate. The default configuration loads the UI's JS assets from a CDN (jsDelivr).


7. Production-Operation Manners: Protection, Versioning, Code Generation in CI

There's a chasm between "works" and "production quality." Let me list the points I always pin down when shipping Flask-smorest to production.

7.1 How to Handle Swagger UI in Production

Swagger UI, handy in development, should sometimes not be exposed as-is in production. There are 2 reasons. (a) Fully exposing the internal API's endpoints, schemas, and parameters hands over a map of the attack surface; (b) with "Try it out," destructive operations against production data can be tried. The countermeasures can be chosen in stages.

StrategyMethodSuited case
Full disablingDon't set OPENAPI_SWAGGER_UI_PATH in the production config (None)A fully internal API, no UI needed
Auth gatePlace the UI-serving path behind Basic auth or an IP restriction via a reverse proxy / extensionWant it viewable only internally
Serve JSON onlyServe openapi.json but not the UIConsumers read it with their own tools
Full publicationPublish including the UIPublic APIs, partner portals

The crux is that the config switches per environment. Per the "12-factor-izing of config" covered in the Flask production-operations guide, inject OPENAPI_SWAGGER_UI_PATH from environment variables / instance config, and switch like disabled in production, enabled in development. Don't write a fixed value in the code.

⚠️ "Exposing Swagger UI in production = an instant security incident" is not true. For an auth-required API, Try it out also requires auth, so it's not unconditionally dangerous. That said, the cost of "handing out a map of the attack surface for free" is always there, so unless it's a public API, make an auth gate or disabling the default — that's the safe-side judgment.

7.2 Versioning: url_prefix or Multiple Apis

API versioning can be expressed straightforwardly with smorest's Blueprint url_prefix.

v1 = Blueprint("orders_v1", "orders_v1", url_prefix="/api/v1/orders", description="Orders API v1")
v2 = Blueprint("orders_v2", "orders_v2", url_prefix="/api/v2/orders", description="Orders API v2")

api.register_blueprint(v1)
api.register_blueprint(v2)

Register multiple-version Blueprints to the same Api, and v1 / v2 line up in one OpenAPI spec. If you want to completely separate the specs per version, you can also take the configuration of standing up multiple Api instances and serving each at a different OPENAPI_URL_PREFIX. The design judgment of URL-path versioning (path vs header vs media type) I leave to the Flask REST API design guide.

7.3 Statically Generating openapi.json in CI to Connect to Client Type Safety

This is where Flask-smorest's investment pays off the most. The OpenAPI spec shouldn't end as just "documentation humans read" — that's a waste. You can make it the input for machines to read and auto-generate a typed client.

In CI, write the OpenAPI spec out to a static file. You can get the spec from Flask-smorest's Api.

# scripts/dump_openapi.py — CIで実行し openapi.json を成果物にする
import json

from myapp import create_app


def main() -> None:
    app = create_app()
    api = app.extensions["flask-smorest"]["apis"][""]["ext_obj"]
    spec = api.spec.to_dict()
    with open("openapi.json", "w", encoding="utf-8") as f:
        json.dump(spec, f, ensure_ascii=False, indent=2, sort_keys=True)


if __name__ == "__main__":
    main()
# CI(GitHub Actions 等)でのフロー例
python scripts/dump_openapi.py            # サーバから仕様を抽出
npx @openapitools/openapi-generator-cli generate \
  -i openapi.json -g typescript-fetch -o ./generated-client
# 生成された型付きクライアントをフロント / モバイルが import

Once this flow is complete, a type-safe pipeline forms: a backend schema change → openapi.json updated in CI → client types regenerated → breaking changes detected immediately as compile errors. The contract between server and client is guaranteed by the type system, not by human attentiveness. The whole picture of this "end-to-end type safety centered on OpenAPI" is argued in detail in the Next.js × Go end-to-end type-safety guide. Even if the backend is Flask, you can enjoy the same type-safety benefit by going through OpenAPI.

💡 The hygiene of tags / operationId: an auto-generated client's method names derive from OpenAPI's operationId. Give each Blueprint a meaningful name and description, and write a clear docstring per endpoint, and the generated client's code becomes readable. Be conscious that "the quality of the docs = the quality of the client code."


8. Technology Selection: An Honest Comparison of smorest / APIFlask / Hand-Written / FastAPI

Flask-smorest isn't the only right answer. There are multiple means to obtain OpenAPI docs, each with its right place. Following the principle that technology selection is a matter of "fit," not "superiority", let me show an honest comparison.

8.1 The Option of APIFlask

A strong alternative to Flask-smorest is APIFlask 3.1.1. It's "a lightweight Flask-based Web API framework," and like smorest auto-generates the OpenAPI spec, Swagger UI, and ReDoc. The biggest difference is that its schema adapter is swappable, handling both marshmallow schemas and Pydantic models (Pydantic support is a relatively new feature added in the 3.x line).

Flask-smorest : marshmallow 一択(marshmallow に最適化)
APIFlask      : marshmallow / Pydantic を選べる(pluggable schema adapter)

If you've already invested in Pydantic, or want to hold your schema assets in Pydantic looking ahead to a future FastAPI migration — in such cases APIFlask's Pydantic support works. Conversely, if you've already solidified your boundary design with marshmallow (many readers of this cluster have), the marshmallow-native smorest is straightforward.

8.2 A Decision Table: When to Choose What

OptionOpenAPI auto-generationSchemaRuntimeWhen to choose
Hand-written FlaskNone (write it yourself)Apply marshmallow etc. by handWSGIAn extremely small API of 1–2 endpoints. No docs needed
Flask-smorestYes (automatic)marshmallowWSGIWant a contract while leveraging existing Flask + marshmallow assets
APIFlaskYes (automatic)marshmallow or PydanticWSGIWant to use Pydantic while staying Flask / looking ahead to migration
FastAPIYes (standard)Pydantic (type hints)ASGINew, high-concurrency IO, type-hint-driven, want to use async fully

The decision can be organized thus.

  • A new project that can go to ASGI, where you want to maximize type-hint-driven async → FastAPI. OpenAPI comes standard.
  • You can't move off existing Flask (WSGI), or don't want to → Flask-smorest / APIFlask. Get OpenAPI while staying WSGI.
  • Within that, smorest if you have marshmallow assets, APIFlask if you want to write in Pydantic.
  • An extremely small internal API needing no contract in the first place → hand-written. But beware the premise that "no contract needed" crumbles over time. The moment endpoints exceed 3 and consumers become a separate team, the value of bringing in smorest stands up.

💡 "Flask, so OpenAPI is impossible" is a misunderstanding. FastAPI's OpenAPI auto-generation is certainly powerful, but it's not something "obtainable only with FastAPI." Flask-smorest / APIFlask provide equivalent value while staying WSGI. Weigh the cost of a framework migration (WSGI → ASGI) and the value of documentation automation on separate scales. "Migrate to FastAPI because I want OpenAPI" is, in many cases, overkill.


9. A Worked Example: A Documented /api/v1/orders Resource

Let me land the theory in a form actually needed in a B2B SaaS. I assemble listing and creating orders as a single resource complete with validation, shaping, pagination, errors, and documentation.

9.1 The Schema: The Contract's Single Source of Truth

# schemas.py
import marshmallow as ma


class OrderSchema(ma.Schema):
    """受注リソース。dump_only で読み取り専用、required で必須を宣言する。"""

    id = ma.fields.Int(dump_only=True)
    order_number = ma.fields.String(dump_only=True)
    customer_id = ma.fields.Int(required=True)
    amount = ma.fields.Decimal(required=True, as_string=True, validate=ma.validate.Range(min=0))
    status = ma.fields.String(
        dump_only=True,
        validate=ma.validate.OneOf(["pending", "confirmed", "shipped", "cancelled"]),
    )
    created_at = ma.fields.DateTime(dump_only=True)


class OrderQueryArgsSchema(ma.Schema):
    """一覧の絞り込み条件。query から読む。"""

    customer_id = ma.fields.Int()
    status = ma.fields.String(validate=ma.validate.OneOf(["pending", "confirmed", "shipped", "cancelled"]))

id / order_number / status / created_at are dump_only — read-only attributes the server numbers / manages, which the client can't input (preventing mass assignment). customer_id / amount are required, auto-422 if missing. This single schema simultaneously drives input validation, output shaping, and the OpenAPI spec.

9.2 The Resource: View = Documentation

# views.py
from flask.views import MethodView
from flask_smorest import Blueprint, abort

from .schemas import OrderSchema, OrderQueryArgsSchema
from .service import OrderService

blp = Blueprint(
    "orders",
    "orders",
    url_prefix="/api/v1/orders",
    description="受注の一覧取得・作成を行う API",
)


@blp.route("/")
class Orders(MethodView):
    @blp.arguments(OrderQueryArgsSchema, location="query")
    @blp.response(200, OrderSchema(many=True))
    @blp.paginate()
    def get(self, filters, pagination_parameters):
        """受注を一覧する(顧客・ステータスで絞り込み可、ページネーション対応)"""
        total, items = OrderService.list(
            filters=filters,
            first_item=pagination_parameters.first_item,
            last_item=pagination_parameters.last_item,
        )
        pagination_parameters.item_count = total
        return items

    @blp.arguments(OrderSchema)
    @blp.response(201, OrderSchema)
    def post(self, new_order):
        """受注を作成する"""
        if not OrderService.customer_exists(new_order["customer_id"]):
            abort(422, message="customer_id が存在しません")
        return OrderService.create(new_order)

Read the contract this code generates.

  • GET /api/v1/orders: filterable in query by customer_id / status, pageable with page / page_size (default 10, upper bound 100), returns an array of Order with 200, total count in the X-Pagination header
  • POST /api/v1/orders: the body is OrderSchema (customer_id / amount required, id etc. not accepted), returns Order with 201 on success, 422 on validation failure, 422 on a business error (with message)

All of these are automatically loaded into Swagger UI. The front-end team can "Try it out" in the UI to try creating an order, an external partner reads the spec in ReDoc, and CI generates a typed client from openapi.json. In my B2B SaaS, being able to provide this "hittable contract" accelerated everything — the in-house front-end implementation, partner integration, and sales' technical explanations. The effort to write documentation separately is zero — because writing the schema and the view becomes the contract itself.

💡 Notice that business logic is offloaded to the Service layer (OrderService), and the view sticks to a thin conversion of "HTTP ↔ schema ↔ service." This is the same thought as the Router → Schema → Model layered separation of the marshmallow × Flask × SQLAlchemy guide. smorest merely layers "documentation automation" onto the Router layer; it doesn't break the layers' separation of responsibilities.


10. Summary: Write a Schema, and You Get a Contract

The essence of Flask-smorest is integrating "writing views and schemas" and "maintaining a machine-readable contract" into the same single act. The OpenAPI docs FastAPI gets with type hints, Flask can get with a marshmallow schema, while staying WSGI. The era of "Flask, so docs are hand-written" is over.

The key points, one more time at the end.

  • A hand-written Flask API has no machine-readable contract. That produces diverging docs, verbal spec-conveyance, and missed breaking changes
  • Flask-smorest is a bundle of Flask + marshmallow + webargs + apispec. A single schema simultaneously drives input validation, output shaping, and the OpenAPI spec (the contract's single source of truth)
  • With @blp.arguments / @blp.response / @blp.paginate / abort, you can write validation, shaping, paging, and errors declaratively, and all of it is auto-documented
  • In production, protect Swagger UI per environment, and statically generate openapi.json in CI to connect to client type safety
  • Choose the means from smorest / APIFlask / hand-written / FastAPI, by runtime (WSGI/ASGI) and schema assets (marshmallow/Pydantic)

An OpenAPI / Documentation-Automation Checklist

#ItemWhat to confirm
1Mandatory configDid you set API_TITLE / API_VERSION / OPENAPI_VERSION?
2Import sourceAre you importing Blueprint / abort from flask_smorest (not from flask)?
3Input boundaryDid you attach @blp.arguments to all inputs and state location explicitly?
4Output boundary@blp.response(code, Schema) on all responses, many=True for lists
5Read-onlyDid you make server-numbered attributes dump_only and prevent mass assignment?
6The Response-return trapDo you understand that returning a Response skips dump, and return dict/ORM where you want shaping?
7Pagination@blp.paginate() on lists, set item_count, documented X-Pagination?
8ErrorsConsistent errors with abort(code, message=...), are 422/404 in the spec?
9Serving the specDid you set OPENAPI_URL_PREFIX (with default None it isn't served)?
10Enabling the UIDid you set OPENAPI_SWAGGER_UI_PATH / OPENAPI_REDOC_PATH by use?
11Production protectionDid you disable / auth-gate Swagger UI in production (switching per environment)?
12VersioningDid you express versions with url_prefix or multiple Apis?
13DocstringA summary-making docstring on each view, a description on the Blueprint
14CI integrationDid you statically generate openapi.json in CI and connect it to client type generation?
15Schema selectionDid you choose marshmallow (smorest) or Pydantic (APIFlask) to match your assets?

Flask is a "core-only" framework. That's exactly why what you load onto it decides production quality. Automating OpenAPI docs is an "outside-the-core" of extremely high value to load. Write one schema, and validation, shaping, and documentation all flow from one truth — that discipline makes a trustworthy API you can hand to another team.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading