Skip to content

feat: implements openapi to scanapi.yaml convertion#866

Open
guites wants to merge 38 commits intoscanapi:mainfrom
guites:feat/convert-openapi-spec
Open

feat: implements openapi to scanapi.yaml convertion#866
guites wants to merge 38 commits intoscanapi:mainfrom
guites:feat/convert-openapi-spec

Conversation

@guites
Copy link
Copy Markdown
Contributor

@guites guites commented Mar 4, 2026

Description

Implements a OpenAPI specification to ScanAPI.yaml convertion.

Motivation behind this PR?

Increasing ScanAPI adoption by reducing friction is a long going issue (see #814).

This PR attempts to provide a sample script that converts OpenAPI v3 files, which are very popular, into a scanapi.yaml file.

The objective is to give project maintainers a starting point into implementation ScanAPI to their pipelines.

I'm referring to this starting scanapi.yaml file as a skeleton file. The skeleton should include (by running the convertion script once):

  • One request for each existing endpoint listed on the OpenAPI spec.
  • Each request should have a simple test that checks whether the expected HTTP status for that endpoint is being returned.
  • Each request (that expects a path variable in the URL) should have the URL pre populated with a custom variable.
  • Each request (that expects an HTTP body) should have the body pre populated with custom variables.
  • Each request (that expects authentication) should have the "headers" section pre populated with custom variables.

Benefits to this approach:

  1. An adopting project can quickly set up a custom scanapi.yaml file;
  2. The adopting project immediately benefits by receiving a set of automated "sanity tests" (with minimal tinkering)

Some downsides:

  1. After running the convertion script, the project maintainer will be expected to fill missing information such as creating custom variables and/or environment variables. I don't think this is a huge issue because it's less work than starting from scratch
  2. After generating the skeleton file, any changes to it would be lost if the convertion script is ran again. This could be prevented by implementing a "sync" mechanism, where additions or removals from the OpenAPI spec file are reflected on the scanapi.yaml file. I think this is out of the scope of the current implementation.
  3. This adds a few dependencies (namely, two) to our project: prance (https://github.com/RonnyPfannschmidt/prance) and openapi-spec-validator (https://github.com/python-openapi/openapi-spec-validator).

Example usage

Let's take the Futurama API project as an example. It's swagger documentation can be accessed here: https://futuramaapi.com/swagger#/ .

We can download the OpenAPI spec file (from https://futuramaapi.com/openapi.json) and run the following command:

$ uv run scanapi convert openapi.json -o scanapi-futurama.yaml -b https://futuramaapi.com
OpenAPI/Swagger version detected: 3.1.0

The following variables were created in the generated ScanAPI YAML file:
- ${Create_User_surname}
- ${Character_Sse_character_id}
- ${Get_Link_link_id}
- ${Create_User_username}
- ${Create_Favorite_Character_character_id}
- ${Create_User_name}
- ${Create_User_password}
- ${Get_User_Auth_Token_username}
- ${Episode_Callback_episode_id}
- ${Character_Callback_callbackUrl}
- ${Get_User_Auth_Token_password}
- ${Create_User_email}
- ${Get_Secret_Message_url}
- ${bearer_token}
- ${Create_Secret_Message_text}
- ${Character_Callback_character_id}
- ${Season_Callback_callbackUrl}
- ${Episode_episode_id}
- ${Character_character_id}
- ${Delete_Favorite_Character_character_id}
- ${Request_Change_User_Password_email}
- ${Season_season_id}
- ${Create_Link_url}
- ${Season_Callback_season_id}
- ${Episode_Callback_callbackUrl}
- ${Get_Refreshed_User_Auth_Token_refresh_token}
See https://scanapi.dev/docs_v1/specification/custom_variables and https://scanapi.dev/docs_v1/specification/environment_variables for more information.

File successfully converted and exported as "scanapi-futurama.yaml"!

This would result in the following ScanAPI yaml:

$ cat scanapi-futurama.yaml

endpoints:
-   name: FastAPI
    path:  https://futuramaapi.com
    requests:
    -   name: Character_Callback
        path: /api/callbacks/characters/${Character_Callback_character_id}
        method: post
        tests:
        -   name: status_code_is_201
            assert: ${{response.status_code == 201}}
        body:
            callbackUrl: ${Character_Callback_callbackUrl}
    -   name: Episode_Callback
        path: /api/callbacks/episodes/${Episode_Callback_episode_id}
        method: post
        tests:
        -   name: status_code_is_201
            assert: ${{response.status_code == 201}}
        body:
            callbackUrl: ${Episode_Callback_callbackUrl}
    -   name: Season_Callback
        path: /api/callbacks/seasons/${Season_Callback_season_id}
        method: post
        tests:
        -   name: status_code_is_201
            assert: ${{response.status_code == 201}}
        body:
            callbackUrl: ${Season_Callback_callbackUrl}
    -   name: Random_Character
        path: /api/random/character
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Random_Episode
        path: /api/random/episode
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Random_Season
        path: /api/random/season
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Character
        path: /api/characters/${Character_character_id}
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Characters
        path: /api/characters
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Create_Secret_Message
        path: /api/crypto/secret_message
        method: post
        tests:
        -   name: status_code_is_201
            assert: ${{response.status_code == 201}}
        body:
            text: ${Create_Secret_Message_text}
    -   name: Get_Secret_Message
        path: /api/crypto/secret_message/${Get_Secret_Message_url}
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Episode
        path: /api/episodes/${Episode_episode_id}
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Episodes
        path: /api/episodes
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Character_Sse
        path: /api/notifications/sse/characters/${Character_Sse_character_id}
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Season
        path: /api/seasons/${Season_season_id}
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Seasons
        path: /api/seasons
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Get_User_Auth_Token
        path: /api/tokens/users/auth
        method: post
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
        body:
            username: ${Get_User_Auth_Token_username}
            password: ${Get_User_Auth_Token_password}
    -   name: Get_Refreshed_User_Auth_Token
        path: /api/tokens/users/refresh
        method: post
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
        body:
            refresh_token: ${Get_Refreshed_User_Auth_Token_refresh_token}
    -   name: Create_User
        path: /api/users
        method: post
        tests:
        -   name: status_code_is_201
            assert: ${{response.status_code == 201}}
        body:
            name: ${Create_User_name}
            surname: ${Create_User_surname}
            email: ${Create_User_email}
            username: ${Create_User_username}
            password: ${Create_User_password}
    -   name: List_Users
        path: /api/users
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
    -   name: Update_User
        path: /api/users
        method: put
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
        headers:
            Authorization: Bearer ${bearer_token}
    -   name: User_Me
        path: /api/users/me
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
        headers:
            Authorization: Bearer ${bearer_token}
    -   name: Resend_User_Confirmation
        path: /api/users/confirmations/resend
        method: post
        tests:
        -   name: status_code_is_202
            assert: ${{response.status_code == 202}}
        headers:
            Authorization: Bearer ${bearer_token}
    -   name: Request_Change_User_Password
        path: /api/users/passwords/request-change
        method: post
        tests:
        -   name: status_code_is_202
            assert: ${{response.status_code == 202}}
        body:
            email: ${Request_Change_User_Password_email}
    -   name: Create_Link
        path: /api/links
        method: post
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
        body:
            url: ${Create_Link_url}
        headers:
            Authorization: Bearer ${bearer_token}
    -   name: List_Links
        path: /api/links
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
        headers:
            Authorization: Bearer ${bearer_token}
    -   name: Get_Link
        path: /api/links/${Get_Link_link_id}
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
        headers:
            Authorization: Bearer ${bearer_token}
    -   name: Create_Favorite_Character
        path: /api/favorites/characters/${Create_Favorite_Character_character_id}
        method: post
        tests:
        -   name: status_code_is_204
            assert: ${{response.status_code == 204}}
        headers:
            Authorization: Bearer ${bearer_token}
    -   name: Delete_Favorite_Character
        path: /api/favorites/characters/${Delete_Favorite_Character_character_id}
        method: delete
        tests:
        -   name: status_code_is_204
            assert: ${{response.status_code == 204}}
        headers:
            Authorization: Bearer ${bearer_token}
    -   name: List_Favorite_Characters
        path: /api/favorites/characters
        method: get
        tests:
        -   name: status_code_is_200
            assert: ${{response.status_code == 200}}
        headers:
            Authorization: Bearer ${bearer_token}

Points of interest:

  1. Endpoints that require authentication have a headers entry with Authorization: Bearer ${bearer_token}. If we defined the bearer_token anywhere on the file, we have working authentication for these endpoints.
  2. Endpoints with path parameters (such as /api/characters/${Character_character_id}) have the path parameter as a variable. This means we can quickly implement this variable in another endpoint.
  3. Endpoints with a body have that body pre filled with variables.

What type of change is this?

Implementation of a new feature.

Checklist

  • A changelog entry was added, or this PR does not require one. Instructions
  • Unit tests were added or updated as needed, or not required for this change. Instructions
  • All unit tests pass locally. Instructions
  • Docstrings or comments were added or updated as needed, or no documentation changes were required. Instructions
  • This PR does not significantly reduce code or docstring coverage.
  • Code follows the project’s style guidelines.
  • ScanAPI was run locally and the changes were manually verified, or this was not necessary. Instructions

Issue

Closes #<issue_number>

@guites guites marked this pull request as ready for review March 4, 2026 21:28
@guites guites requested review from a team as code owners March 4, 2026 21:28
@guites
Copy link
Copy Markdown
Contributor Author

guites commented Mar 4, 2026

Hey :) I think the code reached its final form. I'm marking it as ready for commit, but I'll still add tests for the convert method, which should bump coverage back to 98%!

@Pradhvan
Copy link
Copy Markdown
Member

Pradhvan commented Mar 5, 2026

This looks such a good feature. Let me review this. 🚀

guites added 4 commits March 5, 2026 10:12
do not instantiate third party libraries inside the class responsible for convertion logic,
this facilitates if we ever need to change dependencies (we only need the parsed schema as a
python dictionary).

add a new validation layer for invalid yaml and broken openapi schemas

move printing to the intermediate layer (convertion module shouldn't be responsible for
communicating information to the user)
@guites
Copy link
Copy Markdown
Contributor Author

guites commented Mar 5, 2026

I've finished implementing the test suite :) Now there are some issues from DeepSource, but I'll leave it as is for now and fix them based on what is discussed tomorrow at the next ScanAPI Office Hours.

@guites
Copy link
Copy Markdown
Contributor Author

guites commented Apr 12, 2026

I've fixed most of deepsource problems and ignored all documentation/formatting related issues (see https://app.deepsource.com/gh/scanapi/scanapi/run/0ae4f501-371d-478b-b552-235b85f2473f/python/). I think we can go ahead and skip deepsource output altogether for this PR.

@guites
Copy link
Copy Markdown
Contributor Author

guites commented Apr 12, 2026

Our convertion pipeline is based on prance (https://github.com/RonnyPfannschmidt/prance) using the openapi-spec-validator backend (https://github.com/p1c2u/openapi-spec-validator).

The default behavior is to block parsing of schemas with non-compliant fields.

For example the following schema (the "example" field is defined incorrectly under a response definition. see https://spec.openapis.org/oas/v3.1.0#response-object) :

openapi: 3.0.4
info:
  title: Sample API
  version: 0.1.9

paths:
  /users:
    get:
      summary: Returns a list of users.
      description: Optional extended description in CommonMark or HTML.
      responses:
        "200":
          description: A JSON array of user names
          example: "teste"
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string

Would result in the following error (when running uv run scanapi from example.yaml):

ERROR Couldn't parse OpenAPI schema: ("{'description': 'A JSON array of user names', 'example': 'teste', 'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'string'}}}}} is
not valid under any of the given schemas", 'oneOf', deque(['paths', '/users', 'get', 'responses', '200']), None, [<ValidationError: "'example' does not match any of the regexes: '^x-'">,
<ValidationError: "'$ref' is a required property">], [{'$ref': '#/definitions/Response'}, {'$ref': '#/definitions/Reference'}], {'description': 'A JSON array of user names', 'example': 'teste',
'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'string'}}}}}, {'oneOf': [{'$ref': '#/definitions/Response'}, {'$ref': '#/definitions/Reference'}]},
deque(['properties', 'paths', 'patternProperties', '^\/', 'patternProperties', '^(get|put|post|delete|options|head|patch|trace)$', 'properties', 'responses', 'patternProperties',
'^1-5$', 'oneOf']), None)

The specific error (in this case <ValidationError: "'example' does not match any of the regexes: '^x-'">, <ValidationError: "'$ref' is a required property">) depends on the OpenAPI version.

If we change version 3.0.4 to 3.1.0, keeping the same schema, we get the following error:

ERROR Couldn't parse OpenAPI schema: ("Unevaluated properties are not allowed ('example' was unexpected)", 'unevaluatedProperties', deque(['paths', '/users', 'get', 'responses', '200']), None, [],
False, {'description': 'A JSON array of user names', 'example': 'teste', 'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'string'}}}}}, {'$comment':
'https://spec.openapis.org/oas/v3.1.0#response-object', 'type': 'object', 'properties': {'description': {'type': 'string'}, 'headers': {'type': 'object', 'additionalProperties': {'$ref':
'#/$defs/header-or-reference'}}, 'content': {'$ref': '#/$defs/content'}, 'links': {'type': 'object', 'additionalProperties': {'$ref': '#/$defs/link-or-reference'}}}, 'required': ['description'],
'$ref': '#/$defs/specification-extensions', 'unevaluatedProperties': False}, deque(['properties', 'paths', 'patternProperties', '^/', 'else', 'properties', 'get', 'properties', 'responses',
'patternProperties', '^1-5$', 'else', 'unevaluatedProperties']), None)

The error changed to "Unevaluated properties are not allowed ('example' was unexpected)".

I think it's pretty common for OpenAPI specs to have incorrect/incompatible fields, since there are so many spec generation tools out there.

cc @camilamaia wdyt? Should we move forward with a "strict" implementation for now and study having a --lax option later on, or do you think this is a blocker?

prance seems to accept a Strict flag (see https://github.com/RonnyPfannschmidt/prance/blob/main/prance/__init__.py#L128) but it still isn't clear how it works or how we could send this flag over.

edit: the strict flag on prance works by stringifying integer keys, so it's unrelated to our non-default keys problem

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants