Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce common responses #22

Open
zupo opened this issue May 22, 2019 · 4 comments
Open

Enforce common responses #22

zupo opened this issue May 22, 2019 · 4 comments
Labels
good first issue Good for newcomers

Comments

@zupo
Copy link
Collaborator

zupo commented May 22, 2019

Use case: I want to make sure that all of my endpoints define at least some responses. For example, I want all endpoints to define 200, 404, 403 and 500. If not, fail app startup.

We have a similar thing already built for https://app.woocart.com/api/v1/, but it's a script that we run in CI. And I guess other people probably have a similar use case. I'm pasting the files below, as a starting point.

openapi_responses.yaml:

# Required response definitions
required_responses:
  get:
    - '200'
    - '400'
    - '403'
    - '5XX'
  post:
    - '200'
    - '400'
    - '403'
    - '5XX'
  put:
    - '200'
    - '400'
    - '403'
    - '5XX'
  patch:
    - '200'
    - '400'
    - '403'
    - '5XX'
  delete:
    - '200'
    - '400'
    - '403'
    - '5XX'

# Additional required response definitions for endpoints with parameters
required_responses_params:
  get:
    - '404'
  post:
    - '404'
  put:
    - '404'
  patch:
    - '404'
  delete:
    - '404'

# Allowed missing response definitions
allowed_missing_responses:
  /user/login/:
    post:
      - '403'
  /user/logout/:
    post:
      - '200'
      - '403'
  /openapi_validation/{formatter_name}/:
    post:
      - '403'
  /stores/{storeId}/backups/{backupId}/download:
    get:
      - '200'

check_openapi_responses.py

"""Verify that endpoints have defined required responses."""

from pathlib import Path

import argparse
import openapi_core
import sys
import typing as t
import yaml


def get_config(config_path: str) -> t.Dict:
    """Read config from file."""
    config = Path(config_path)

    if not config.is_file():
        sys.stdout.write(f"ERROR Config file not found on: {config}\n")
        sys.exit(1)

    with open(config, "r") as f:
        return yaml.safe_load(f)


def get_spec(schema_path: str) -> t.Any:
    """Create openapi spec from schema."""
    schema = Path(schema_path)

    if not schema.is_file():
        sys.stdout.write(f"ERROR schema file not found on: {schema}\n")
        sys.exit(1)

    with open(schema, "r") as f:
        return openapi_core.create_spec(yaml.safe_load(f))


def required_responses(
    config: t.Dict, endpoint: str, method: str, has_params: bool
) -> t.Set:
    """Get required responses for given method on endpoint."""
    required_responses: t.Set = set(
        config.get("required_responses", {}).get(method, [])
    )
    if has_params:
        required_params: t.Set = set(
            config.get("required_responses_params", {}).get(method, [])
        )
        required_responses = required_responses.union(required_params)
    allowed_missing: t.Set = set(
        config.get("allowed_missing_responses", {}).get(endpoint, {}).get(method, [])
    )
    required_responses = required_responses - allowed_missing
    return required_responses


def check_responses(spec, config: t.Dict) -> None:
    """Verify that all endpoints have defined required responses."""
    check_failed = False
    missing_responses_count = 0

    for path in spec.paths.values():
        for operation in path.operations.values():
            operation_responses = operation.responses.keys()
            method = operation.http_method
            endpoint = operation.path_name
            has_params = len(operation.parameters) > 0
            required = required_responses(config, endpoint, method, has_params)

            missing_responses = required - operation_responses
            for missing_response in missing_responses:
                check_failed = True
                missing_responses_count += 1
                sys.stdout.write(
                    "ERROR missing response "
                    f"'{missing_response}' for '{method}' request on path "
                    f"'{endpoint}'\n"
                )
    if check_failed:
        sys.stdout.write(
            "\nFAILED: Openapi responses check: "
            f"{missing_responses_count} missing response definitions. \n"
        )
        sys.exit(1)
    else:
        sys.exit(0)


def main(argv=sys.argv) -> None:  # pragma: no cover
    """Run endpoints responses check."""
    parser = argparse.ArgumentParser(
        usage=(
            "python openapi_responses.py --schema openapi.yaml --config responses.yaml \n"
        )
    )
    parser.add_argument(
        "--schema",
        "-s",
        required=True,
        type=str,
        metavar="<schema>",
        help="File with openapi schema.",
    )
    parser.add_argument(
        "--config",
        "-c",
        nargs="?",
        type=str,
        metavar="<config>",
        help="File with exceptions for responses.",
    )
    args = parser.parse_args()

    config = {}
    if args.config:
        config = get_config(args.config)
    spec = get_spec(args.schema)

    check_responses(spec, config)


if __name__ == "__main__":
    main()
@zupo zupo added the good first issue Good for newcomers label Jul 31, 2019
zupo added a commit that referenced this issue Dec 12, 2020
A common pitfall when using this package is the following: you define
an endpoint that can result in 400 Bad Request, but you forget to list 400
in the `responses` section of your endpoint in openapi.yaml. This package
then instead returns 500 Internal Server error, because it keeps the promise
that only defined responses will be allowed (unless you set
`enable_request_validation` to `False`, that is).

With this commit, all endpoints, by default need to have 200, 400 and 500
on the list of `responses` in openapi.yaml, otherwise the app won't start. Additionally, all
endpoints that accept a parameter, also need to have 404 on the list of
`responses`.

You can skip this check by setting `enable_endpoint_validation` to `False`.

Refs #22
Refs #49 (comment)
Refs #36
@zupo
Copy link
Collaborator Author

zupo commented Dec 12, 2020

I've drafted how I want the configuration for this functionality to look & feel: #103

Instead of requiring the openapi_responses.yaml file, I opted for simple defaults that can be overridden. In the case of the openapi_responses.yaml provided above, the configuration would look like this (assuming defaults defined in https://github.com/Pylons/pyramid_openapi3/pull/103/files#diff-a8182b0c3377a86ad3bb94d2301b04ff58798bbf01c9257be61d5aa088887a7aR358-R366):

    config.endpoint_validation_overrides = 
    {
        "/user/logout": {"post": [202, 400, 500]},
        "/stores/{storeId}/backups/{backupId}/download:": {"post": [202, 400, 500]},
    }

It's not ideal to not be able to support endpoint_validation_overrides via .ini files. Anyone has a better idea?

Would love to hear feedback and ideas on how to improve the UX of this new check/feature!

zupo added a commit that referenced this issue Dec 12, 2020
A common pitfall when using this package is the following: you define
an endpoint that can result in 400 Bad Request, but you forget to list 400
in the `responses` section of your endpoint in openapi.yaml. This package
then instead returns 500 Internal Server error, because it keeps the promise
that only defined responses will be allowed (unless you set
`enable_request_validation` to `False`, that is).

With this commit, all endpoints, by default need to have 200, 400 and 500
on the list of `responses` in openapi.yaml, otherwise the app won't start. Additionally, all
endpoints that accept a parameter, also need to have 404 on the list of
`responses`.

You can skip this check by setting `enable_endpoint_validation` to `False`.

Refs #22
Refs #49 (comment)
Refs #36
@damonhook
Copy link
Collaborator

damonhook commented Feb 23, 2021

There are 2 cases covered by the OpenApi specification that I just wanted to bring to light here (and maybe discuss how / if they should be handled, or just documented as edge cases in the readme / upcoming docs):

  1. The response HTTP code is actually a patterned field. For example, you could have a 4XX response key, which will handle [400-499] response codes. Ideally, if 400 and 404 are required, and endpoint that has defined 4XX should satisfy these

  2. This one I have not seen as often, but you are allowed to define a default key (which will satisfy all response codes not covered individually).


The first point (4XX / 5XX) would fail when a set intersection is done.

The second point (default) could be easily handled as if an endpoint has one, it automatically passes the required status code check.

@damonhook
Copy link
Collaborator

One option would be to test it ourself.

Another possibility may be to leverage openapi-core for it: If you call the .get_response(self, http_status) function for each required response on each operation. It will throw an exception if it cannot find a matching path in the spec (source)

This way it would solve both points from the previous comment without relying on specialized code inside this library

@zupo
Copy link
Collaborator Author

zupo commented Feb 23, 2021

Fantastic insights, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
good first issue Good for newcomers
Projects
None yet
Development

No branches or pull requests

2 participants