diff --git a/.bandit.rc b/.bandit.rc new file mode 100644 index 0000000..75d550c --- /dev/null +++ b/.bandit.rc @@ -0,0 +1 @@ +skips: ['B101'] diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..9114265 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +[run] +branch = True +omit = tests/* + setup.py +source = . + +[report] +precision = 1 +show_missing = True +ignore_errors = True +exclude_lines = + no cover diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5ed5d60 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.tox +.pytest_cache +tests/__pycache__ +__pycache__ +*.pyc diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6b91533 --- /dev/null +++ b/.flake8 @@ -0,0 +1,15 @@ +[flake8] + +ignore = E203, W503, W504, W605 +max-line-length = 79 +max-complexity = 12 + +exclude = + .git + dist + build + flask_restful_swagger.egg-info + htmlcov + scripts + static + diff --git a/.github/workflows/docker27.yml b/.github/workflows/docker27.yml new file mode 100644 index 0000000..d47335d --- /dev/null +++ b/.github/workflows/docker27.yml @@ -0,0 +1,37 @@ +name: flask_restful_swagger Python2.7 + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Create Docker Mounted Content + run: | + echo | ssh-keygen + touch ${HOME}/.gitconfig + touch ${HOME}/.gitconfig_global + - name: Modify Dockerfile's Python Version + run: | + PYTHON_CONTAINER="python:2.7-slim" + DOCKER_CONTENT=$(tail -n +2 development/Dockerfile) + echo "FROM ${PYTHON_CONTAINER}" > development/Dockerfile + echo "${DOCKER_CONTENT}" >> development/Dockerfile + - name: Ensure File System is Writable by the Container + run: | + sudo chmod -R 777 . + - name: Build Container + run: | + docker-compose build + docker-compose up -d + - name: Run Linter + run: | + docker-compose exec -T flask_restful_swagger bash -l -c 'scripts/commander.sh lint-validate' + - name: Run Sec Test + run: | + docker-compose exec -T flask_restful_swagger bash -l -c 'scripts/commander.sh sectest' + - name: Run Unit Tests + run: | + docker-compose exec -T flask_restful_swagger bash -l -c 'scripts/commander.sh test coverage' diff --git a/.github/workflows/docker37.yml b/.github/workflows/docker37.yml new file mode 100644 index 0000000..8a37c78 --- /dev/null +++ b/.github/workflows/docker37.yml @@ -0,0 +1,37 @@ +name: flask_restful_swagger Python3.7 + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Create Docker Mounted Content + run: | + echo | ssh-keygen + touch ${HOME}/.gitconfig + touch ${HOME}/.gitconfig_global + - name: Modify Dockerfile's Python Version + run: | + PYTHON_CONTAINER="python:3.7-slim" + DOCKER_CONTENT=$(tail -n +2 development/Dockerfile) + echo "FROM ${PYTHON_CONTAINER}" > development/Dockerfile + echo "${DOCKER_CONTENT}" >> development/Dockerfile + - name: Ensure File System is Writable by the Container + run: | + sudo chmod -R 777 . + - name: Build Container + run: | + docker-compose build + docker-compose up -d + - name: Run Linter + run: | + docker-compose exec -T flask_restful_swagger bash -l -c 'scripts/commander.sh lint-validate' + - name: Run Sec Test + run: | + docker-compose exec -T flask_restful_swagger bash -l -c 'scripts/commander.sh sectest' + - name: Run Unit Tests + run: | + docker-compose exec -T flask_restful_swagger bash -l -c 'scripts/commander.sh test coverage' diff --git a/.gitignore b/.gitignore index 3d51968..8ca6637 100644 --- a/.gitignore +++ b/.gitignore @@ -1,38 +1,139 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ *.py[cod] +*$py.class # C extensions *.so -# Packages -*.egg -*.egg-info -dist -build -eggs -parts -bin -var -sdist -develop-eggs +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ .installed.cfg -lib -lib64 -__pycache__ +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec # Installer logs pip-log.txt +pip-delete-this-directory.txt # Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ .coverage -.tox +.coverage.* +.cache nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ # Translations *.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Overrides +override.env + +# Pycharm +.idea -# Mr Developer -.mr.developer.cfg -.project -.pydevproject +# Archives +.archive -*.iml diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..20dbd13 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,9 @@ +[settings] +line_length=80 +indent=' ' +multi_line_output=3 +length_sort=0 +default_section=FIRSTPARTY +no_lines_before=LOCALFOLDER +sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +include_trailing_comma=true diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..632ae48 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at niall@niallbyrne.ca. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ec77d1f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,12 @@ +# Contribution Guide + +[Code of Conduct](./CODE_OF_CONDUCT.md) + +[Contribution Guide](./development/DEVELOPMENT.md) + +# Contacts + +- @rantav +- @niall-byrne + +__This project is part of the [Cloudify Cosmo project](https://github.com/CloudifySource/)__ diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..9534830 --- /dev/null +++ b/Pipfile @@ -0,0 +1,11 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] + +[packages] + +[requires] +python_version = "3.6" diff --git a/README.md b/README.md index 7bdf095..67b4218 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # flask-restful-swagger -### We have a new project lead! -We have a new project lead, @niall-byrne (Dec 2015), thank you Niall! +[![flask_restful_swagger-automation](https://github.com/rantav/flask-restful-swagger/workflows/flask_restful_swagger%20Python2.7/badge.svg)](https://github.com/rantav/flask-restful-swagger/actions)
+[![flask_restful_swagger-automation](https://github.com/rantav/flask-restful-swagger/workflows/flask_restful_swagger%20Python3.7/badge.svg)](https://github.com/rantav/flask-restful-swagger/actions)
## What is flask-restful-swagger? flask-restful-swagger is a wrapper for [flask-restful](http://flask-restful.readthedocs.org/en/latest/) which enables [swagger](https://developers.helloreverb.com/swagger/) support. -In essense, you just need to wrap the Api instance and add a few python decorators to get full swagger support. +In essence, you just need to wrap the Api instance and add a few python decorators to get full swagger support. -## How to: +## Installation: Install: ``` @@ -18,7 +18,19 @@ pip install flask-restful-swagger (This installs flask-restful as well) -And in your program, where you'd usually just use flask-restful, add just a little bit of sauce and get a swagger spec out. +## See Some Quick Examples: + +```bash +PYTHONPATH=. python examples/basic.py +PYTHONPATH=. python examples/blueprints.py +PYTHONPATH=. python examples/inheritance.py +``` + +Browse to: [http://localhost:5000/api/spec.html](http://localhost:5000/api/spec.html) + +## How To: + +In your program, where you'd usually just use flask-restful, add just a little bit of sauce and get a swagger spec out. ```python diff --git a/assets/requirements-dev.txt b/assets/requirements-dev.txt new file mode 100644 index 0000000..a77eb5c --- /dev/null +++ b/assets/requirements-dev.txt @@ -0,0 +1,12 @@ +# Development Requirements +bandit>=1.6.2,<1.7.0 +bs4>=0.0.1,<0.1.0 +commitizen>=0.9.11,<1.17.0 +isort>=4.3.21,<4.4.0 +flake8>=3.7.9,<3.8.0s +mock>=3.0.5,<3.1.0 +pytest>=4.6.9,<5.3.0 +pytest-cov>=2.8.1,<2.9.0 +safety>=1.8.5,<1.9.0 +wheel>=0.34.1,<0.35.0 +yapf>=0.28.0,<0.29.0 diff --git a/assets/requirements.txt b/assets/requirements.txt new file mode 100644 index 0000000..b655887 --- /dev/null +++ b/assets/requirements.txt @@ -0,0 +1,3 @@ +# Application Requirements +Jinja2>=2.10.1,<3.0.0 +Flask-RESTful>=0.3.6 diff --git a/container b/container new file mode 100755 index 0000000..f4149ca --- /dev/null +++ b/container @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +echo "Entering Development Container ..." +docker-compose exec flask_restful_swagger bash diff --git a/development.env b/development.env new file mode 100644 index 0000000..9f952c1 --- /dev/null +++ b/development.env @@ -0,0 +1,3 @@ +PYTHONPATH=/app/flask_restful_swagger/ +GIT_HOOKS=1 +GIT_HOOKS_PROTECTED_BRANCHES="^(master)" diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md new file mode 100644 index 0000000..a1bec6d --- /dev/null +++ b/development/DEVELOPMENT.md @@ -0,0 +1,66 @@ +# flask-restful-swagger + +A Swagger Spec Extractor for Flask-Restful + +## Please Do's! + - Please use commitizen to normalize your commit messages + - Please lint your code before sending pull requests + +## Development Dependencies + +You'll need to install: + - [Docker](https://www.docker.com/) + - [Docker Compose](https://docs.docker.com/compose/install/) + +## Setup the Development Environment + +Build the development environment container (this takes a few minutes): +- `docker-compose build` + +Start the environment container: +- `docker-compose up -d` + +Spawn a shell inside the container: +- `./container` + +## But I Want to Develop in Python2 + +Although Python2 is EOL, we still want to support the existing users out there for as long as we can: + +Modify the first line of `development/Dockerfile` to: +- `FROM python:2.7-slim` + +You should now be able to rebuild your container, and restart your development environment. + + +If you're switching back and forth between Python2 and 3, you'll need to wipe the compiled bytecode.
Run this command inside the container: +- `find . -name *.pyc -delete` + +## Install the Project Packages on your Host Machine: +This is useful for making your IDE aware of what's installed in a venv. + +- `pip install pipenv` +- `source scripts/dev` +- `dev setup` (Installs the requirements.txt in the `assets` folder.) +- `pipenv --venv` (To get the path of the virtual environment for your IDE.) + +## Environment +The [development.env](./development.env) file can be modified to inject environment variable content into the container. + +You can override the values set in this file by setting shell ENV variables prior to starting the container: +- `export GIT_HOOKS_PROTECTED_BRANCHES='.*'` +- `docker-compose kill` (Kill the current running container.) +- `docker-compose rm` (Remove the stopped container.) +- `docker-compose up -d` (Restart the dev environment, with a new container, containing the override.) +- `./container` + +## Git Hooks +Git hooks are installed that will enforce linting and unit-testing on the specified branches. +The following environment variables can be used to customize this behavior: + +- `GIT_HOOKS` (Set this value to 1 to enable the pre-commit hook) +- `GIT_HOOKS_PROTECTED_BRANCHES` (Customize this regex to specify the branches that should enforce the Git Hook on commit.) + +## CLI Reference +The CLI is enabled by default inside the container, and is also available on the host machine.
+Run the CLI without arguments to see the complete list of available commands: `$ dev` diff --git a/development/Dockerfile b/development/Dockerfile new file mode 100644 index 0000000..ba58241 --- /dev/null +++ b/development/Dockerfile @@ -0,0 +1,43 @@ +FROM python:3.7-slim +# Default Development Version + +MAINTAINER rantav@gmail.com +LABEL PROJECT=flask_restful_swagger + +ENV PYTHONUNBUFFERED 1 + +# Mark Container +RUN echo "flask_restful_swagger" > /etc/container_release + +# Install Dependencies +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y \ + bash \ + build-essential \ + curl \ + jq \ + openssh-client \ + shellcheck \ + sudo \ + tig \ + vim + +# Setup directories +RUN mkdir -p /home/user /app +WORKDIR /app + +# Copy the codebase +COPY . /app + +# Create the runtime user, and change permissions +RUN useradd user -d /home/user \ + -s /bin/bash \ + -M \ + && chown -R user:user /home/user \ + && chown -R user:user /app \ + && echo "user ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers +USER user + +# Setup The Dev CLI +RUN scripts/commander.sh setup diff --git a/development/bash/.bash_customize b/development/bash/.bash_customize new file mode 100644 index 0000000..829bfb0 --- /dev/null +++ b/development/bash/.bash_customize @@ -0,0 +1,2 @@ +# Customize Your Path Here +export PATH="/home/user/.local/bin:${PATH}" diff --git a/development/bash/.bash_git b/development/bash/.bash_git new file mode 100644 index 0000000..20de5dc --- /dev/null +++ b/development/bash/.bash_git @@ -0,0 +1,87 @@ +#!/bin/bash + +# Do Not Modify This File, It's Intended To Be Updated From Time to TIme +# INSTEAD: add additional functionality though the .bash_customize file. + +# ---------------------------------------------------------------- +# Bash Git Support - Show Git Repo Information in Bash Prompt +# ---------------------------------------------------------------- + +env_colors() { + # Normal Colors + Black='\033[30m' # Black + Red='\033[31m' # Red + Green='\033[32m' # Green + Yellow='\033[33m' # Yellow + Blue='\033[34m' # Blue + Purple='\033[35m' # Purple + Cyan='\033[36m' # Cyan + White='\033[37m' # White + + # Bold + BBlack='\033[30m' # Black + BRed='\033[31m' # Red + BGreen='\033[32m' # Green + BYellow='\033[33m' # Yellow + BBlue='\033[34m' # Blue + BPurple='\033[35m' # Purple + BCyan='\033[36m' # Cyan + BWhite='\033[37m' # White + + # Background + On_Black='\033[40m' # Black + On_Red='\033[41m' # Red + On_Green='\033[42m' # Green + On_Yellow='\033[43m' # Yellow + On_Blue='\033[44m' # Blue + On_Purple='\033[45m' # Purple + On_Cyan='\033[46m' # Cyan + On_White='\033[47m' # White + + NC="\033[0m" # Color Reset +} + +find_git_dirty() { + local git_dirty + local status + env_colors + + status=$(git status --porcelain 2> /dev/null) + if [[ "$status" != "" ]]; then + git_dirty="${BRed}*${NC}" + else + git_dirty="" + fi + + echo -en "${git_dirty}" + +} + +find_git_branch() { + env_colors + + local branch + local git_branch + local repository_name + + if branch=$(git rev-parse --abbrev-ref HEAD 2> /dev/null); then + if [[ "$branch" == "HEAD" ]]; then + branch='detached*' + fi + + repository_name=$(git rev-parse --show-toplevel) + repository_name=$(basename "${repository_name}") + + git_branch="[r:${Yellow}${repository_name}${NC}/b:${Cyan}${branch}${NC}]\n" + else + git_branch="" + fi + echo -en "${git_branch}" +} + +git_status() { + find_git_dirty + find_git_branch +} + +PROMPT_COMMAND="git_status; $PROMPT_COMMAND" diff --git a/development/bash/.bash_profile b/development/bash/.bash_profile new file mode 100644 index 0000000..cc01b2d --- /dev/null +++ b/development/bash/.bash_profile @@ -0,0 +1,6 @@ +#!/bin/bash + +# Do Not Modify This File, It's Intended To Be Updated From Time to TIme +# INSTEAD: add additional functionality though the .bash_customize file. + +source "${HOME}/.bashrc" diff --git a/development/bash/.bashrc b/development/bash/.bashrc new file mode 100644 index 0000000..282ce06 --- /dev/null +++ b/development/bash/.bashrc @@ -0,0 +1,21 @@ +#!/bin/bash + +# Do Not Modify This File, It's Intended To Be Updated From Time to TIme +# INSTEAD: add additional functionality though the .bash_customize file. + +PS1='${git_branch}\n${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ ' + +# Terminal Colors +if [[ -x /usr/bin/dircolors ]]; then + test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)" + alias ls='ls --color=auto' + alias grep='grep --color=auto' + alias fgrep='fgrep --color=auto' + alias egrep='egrep --color=auto' +fi + +set -e + source /app/scripts/dev + source /home/user/.bash_git + source /home/user/.bash_customize +set +e diff --git a/development/bash/README.md b/development/bash/README.md new file mode 100644 index 0000000..3f05b85 --- /dev/null +++ b/development/bash/README.md @@ -0,0 +1,3 @@ +# Bash Environment + +Run the `dev setup` command to re-symlink `.bash_customize` into the container's BASH environment. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..52de0a2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3" + +services: + flask_restful_swagger: + build: + context: . + dockerfile: development/Dockerfile + ports: + - "127.0.0.1:5000:5000" + env_file: + - development.env + volumes: + - ${HOME}/.ssh:/home/user/.ssh + - ${HOME}/.gitconfig:/home/user/.gitconfig + - ${HOME}/.gitconfig_global:/home/user/.gitconfig_global + - ./:/app + command: > + ./flask_restful_swagger/container_boot.sh diff --git a/examples/basic.py b/examples/basic.py index 468c602..65b03cd 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -1,81 +1,90 @@ -''' +""" Running: PYTHONPATH=. python examples/basic.py -''' +""" from flask import Flask, redirect -from flask_restful import reqparse, abort, Api, Resource, fields,\ - marshal_with +from flask_restful import Api, Resource, abort, fields, marshal_with, reqparse + from flask_restful_swagger import swagger -app = Flask(__name__, static_folder='../static') +app = Flask(__name__, static_folder="../static") ################################### # This is important: -api = swagger.docs(Api(app), apiVersion='0.1', - basePath='http://localhost:5000', - resourcePath='/', - produces=["application/json", "text/html"], - api_spec_url='/api/spec', - description='A Basic API') +api = swagger.docs( + Api(app), + apiVersion="0.1", + basePath="http://localhost:5000", + resourcePath="/", + produces=["application/json", "text/html"], + api_spec_url="/api/spec", + description="A Basic API", +) ################################### TODOS = { - 'todo1': {'task': 'build an API'}, - 'todo2': {'task': '?????'}, - 'todo3': {'task': 'profit!'}, + "todo1": {"task": "build an API"}, + "todo2": {"task": "?????"}, + "todo3": {"task": "profit!"}, } def abort_if_todo_doesnt_exist(todo_id): - if todo_id not in TODOS: - abort(404, message="Todo {} doesn't exist".format(todo_id)) + if todo_id not in TODOS: + abort(404, message="Todo {} doesn't exist".format(todo_id)) + parser = reqparse.RequestParser() -parser.add_argument('task', type=str) +parser.add_argument("task", type=str) @swagger.model class TodoItem: - """This is an example of a model class that has parameters in its constructor + """This is an example of a model class that has parameters in its constructor and the fields in the swagger spec are derived from the parameters to __init__. In this case we would have args, arg2 as required parameters and arg3 as optional parameter.""" - def __init__(self, arg1, arg2, arg3='123'): - pass + + def __init__(self, arg1, arg2, arg3="123"): + pass + class Todo(Resource): - "My TODO API" - @swagger.operation( - notes='get a todo item by ID', - nickname='get', - # Parameters can be automatically extracted from URLs (e.g. ) - # but you could also override them here, or add other parameters. - parameters=[ - { - "name": "todo_id_x", - "description": "The ID of the TODO item", - "required": True, - "allowMultiple": False, - "dataType": 'string', - "paramType": "path" - }, - { - "name": "a_bool", - "description": "The ID of the TODO item", - "required": True, - "allowMultiple": False, - "dataType": 'boolean', - "paramType": "path" - } - ]) - def get(self, todo_id): - # This goes into the summary - """Get a todo task + "My TODO API" + + @swagger.operation( + notes="get a todo item by ID", + nickname="get", + # Parameters can be automatically extracted from URLs. + # For Example: + # but you could also override them here, or add other parameters. + parameters=[ + { + "name": "todo_id_x", + "description": "The ID of the TODO item", + "required": True, + "allowMultiple": False, + "dataType": "string", + "paramType": "path", + }, + { + "name": "a_bool", + "description": "The ID of the TODO item", + "required": True, + "allowMultiple": False, + "dataType": "boolean", + "paramType": "path", + }, + ], + ) + def get(self, todo_id): + # This goes into the summary + """Get a todo task This will be added to the Implementation Notes. It lets you put very long text in your api. @@ -88,138 +97,141 @@ def get(self, todo_id): cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. """ - abort_if_todo_doesnt_exist(todo_id) - return TODOS[todo_id], 200, {'Access-Control-Allow-Origin': '*'} - - @swagger.operation( - notes='delete a todo item by ID', - ) - def delete(self, todo_id): - abort_if_todo_doesnt_exist(todo_id) - del TODOS[todo_id] - return '', 204, {'Access-Control-Allow-Origin': '*'} - - @swagger.operation( - notes='edit a todo item by ID', - ) - def put(self, todo_id): - args = parser.parse_args() - task = {'task': args['task']} - TODOS[todo_id] = task - return task, 201, {'Access-Control-Allow-Origin': '*'} - - def options (self, **args): - # since this method is not decorated with @swagger.operation it does not - # get added to the swagger docs - return {'Allow' : 'GET,PUT,POST,DELETE' }, 200, \ - { 'Access-Control-Allow-Origin': '*', \ - 'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE', \ - 'Access-Control-Allow-Headers': 'Content-Type' } + abort_if_todo_doesnt_exist(todo_id) + return TODOS[todo_id], 200, {"Access-Control-Allow-Origin": "*"} + + @swagger.operation(notes="delete a todo item by ID",) + def delete(self, todo_id): + abort_if_todo_doesnt_exist(todo_id) + del TODOS[todo_id] + return "", 204, {"Access-Control-Allow-Origin": "*"} + + @swagger.operation(notes="edit a todo item by ID",) + def put(self, todo_id): + args = parser.parse_args() + task = {"task": args["task"]} + TODOS[todo_id] = task + return task, 201, {"Access-Control-Allow-Origin": "*"} + + def options(self, **args): + # since this method is not decorated with @swagger.operation it does + # not get added to the swagger docs + return ( + {"Allow": "GET,PUT,POST,DELETE"}, + 200, + { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,PUT,POST,DELETE", + "Access-Control-Allow-Headers": "Content-Type", + }, + ) + # TodoList # shows a list of all todos, and lets you POST to add new tasks class TodoList(Resource): + def get(self): + return TODOS, 200, {"Access-Control-Allow-Origin": "*"} + + @swagger.operation( + notes="Creates a new TODO item", + responseClass=TodoItem.__name__, + nickname="create", + parameters=[ + { + "name": "body", + "description": "A TODO item", + "required": True, + "allowMultiple": False, + "dataType": TodoItem.__name__, + "paramType": "body", + } + ], + responseMessages=[ + { + "code": 201, + "message": "Created. The URL of the created blueprint should " + + "be in the Location header", + }, + {"code": 405, "message": "Invalid input"}, + ], + ) + def post(self): + args = parser.parse_args() + todo_id = "todo%d" % (len(TODOS) + 1) + TODOS[todo_id] = {"task": args["task"]} + return TODOS[todo_id], 201, {"Access-Control-Allow-Origin": "*"} - def get(self): - return TODOS, 200, {'Access-Control-Allow-Origin': '*'} - - @swagger.operation( - notes='Creates a new TODO item', - responseClass=TodoItem.__name__, - nickname='create', - parameters=[ - { - "name": "body", - "description": "A TODO item", - "required": True, - "allowMultiple": False, - "dataType": TodoItem.__name__, - "paramType": "body" - } - ], - responseMessages=[ - { - "code": 201, - "message": "Created. The URL of the created blueprint should " + - "be in the Location header" - }, - { - "code": 405, - "message": "Invalid input" - } - ]) - def post(self): - args = parser.parse_args() - todo_id = 'todo%d' % (len(TODOS) + 1) - TODOS[todo_id] = {'task': args['task']} - return TODOS[todo_id], 201, {'Access-Control-Allow-Origin': '*'} @swagger.model class ModelWithResourceFields: - resource_fields = { - 'a_string': fields.String() - } + resource_fields = {"a_string": fields.String()} + @swagger.model @swagger.nested( - a_nested_attribute=ModelWithResourceFields.__name__, - a_list_of_nested_types=ModelWithResourceFields.__name__) + a_nested_attribute=ModelWithResourceFields.__name__, + a_list_of_nested_types=ModelWithResourceFields.__name__, +) class TodoItemWithResourceFields: - """This is an example of how Output Fields work + """This is an example of how Output Fields work (http://flask-restful.readthedocs.org/en/latest/fields.html). Output Fields lets you add resource_fields to your model in which you specify the output of the model when it gets sent as an HTTP response. flask-restful-swagger takes advantage of this to specify the fields in the model""" - resource_fields = { - 'a_string': fields.String(attribute='a_string_field_name'), - 'a_formatted_string': fields.FormattedString, - 'an_enum': fields.String, - 'an_int': fields.Integer, - 'a_bool': fields.Boolean, - 'a_url': fields.Url, - 'a_float': fields.Float, - 'an_float_with_arbitrary_precision': fields.Arbitrary, - 'a_fixed_point_decimal': fields.Fixed, - 'a_datetime': fields.DateTime, - 'a_list_of_strings': fields.List(fields.String), - 'a_nested_attribute': fields.Nested(ModelWithResourceFields.resource_fields), - 'a_list_of_nested_types': fields.List(fields.Nested(ModelWithResourceFields.resource_fields)), - } - - # Specify which of the resource fields are required - required = ['a_string'] - - swagger_metadata = { - 'an_enum': { - 'enum': ['one', 'two', 'three'] - } - } + + resource_fields = { + "a_string": fields.String(attribute="a_string_field_name"), + "a_formatted_string": fields.FormattedString, + "an_enum": fields.String, + "an_int": fields.Integer, + "a_bool": fields.Boolean, + "a_url": fields.Url, + "a_float": fields.Float, + "an_float_with_arbitrary_precision": fields.Arbitrary, + "a_fixed_point_decimal": fields.Fixed, + "a_datetime": fields.DateTime, + "a_list_of_strings": fields.List(fields.String), + "a_nested_attribute": fields.Nested( + ModelWithResourceFields.resource_fields + ), + "a_list_of_nested_types": fields.List( + fields.Nested(ModelWithResourceFields.resource_fields) + ), + } + + # Specify which of the resource fields are required + required = ["a_string"] + + swagger_metadata = {"an_enum": {"enum": ["one", "two", "three"]}} + class MarshalWithExample(Resource): - @swagger.operation( - notes='get something', - responseClass=TodoItemWithResourceFields, - nickname='get') - @marshal_with(TodoItemWithResourceFields.resource_fields) - def get(self, **kwargs): - return {}, 200, {'Access-Control-Allow-Origin': '*'} + @swagger.operation( + notes="get something", + responseClass=TodoItemWithResourceFields, + nickname="get", + ) + @marshal_with(TodoItemWithResourceFields.resource_fields) + def get(self, **kwargs): + return {}, 200, {"Access-Control-Allow-Origin": "*"} -## -## Actually setup the Api resource routing here -## -api.add_resource(TodoList, '/todos') -api.add_resource(Todo, '/todos/') -api.add_resource(MarshalWithExample, '/marshal_with') +# +# Actually setup the Api resource routing here +# +api.add_resource(TodoList, "/todos") +api.add_resource(Todo, "/todos/") +api.add_resource(MarshalWithExample, "/marshal_with") -@app.route('/docs') +@app.route("/docs") def docs(): - return redirect('/static/docs.html') + return redirect("/static/docs.html") -if __name__ == '__main__': - TodoItemWithResourceFields() - TodoItem(1, 2, '3') - app.run(debug=True) +if __name__ == "__main__": + TodoItemWithResourceFields() + TodoItem(1, 2, "3") + app.run(host='0.0.0.0', debug=True) diff --git a/examples/blueprints.py b/examples/blueprints.py index ce5b85f..1930e08 100644 --- a/examples/blueprints.py +++ b/examples/blueprints.py @@ -1,91 +1,103 @@ -''' +""" Running: PYTHONPATH=. python examples/basic.py Goto: http://127.0.0.1:5000/api2/api/spec.html -''' +""" -from flask import Flask, redirect, Blueprint -from flask_restful import reqparse, abort, Api, Resource, fields,\ - marshal_with +from flask import Blueprint, Flask, redirect +from flask_restful import Api, Resource, abort, fields, marshal_with, reqparse + from flask_restful_swagger import swagger -app = Flask(__name__, static_folder='../static') -my_blueprint1 = Blueprint('my_blueprint1', __name__) -my_blueprint2 = Blueprint('my_blueprint2', __name__) +app = Flask(__name__, static_folder="../static") +my_blueprint1 = Blueprint("my_blueprint1", __name__) +my_blueprint2 = Blueprint("my_blueprint2", __name__) ################################### # This is important: -api1 = swagger.docs(Api(my_blueprint1), apiVersion='0.1', - basePath='http://localhost:5000', - resourcePath='/', - produces=["application/json", "text/html"], - api_spec_url='/api/spec', - description='Blueprint1 Description') -api2 = swagger.docs(Api(my_blueprint2), apiVersion='0.1', - basePath='http://localhost:5000', - resourcePath='/', - produces=["application/json", "text/html"], - api_spec_url='/api/spec', - description='Blueprint2 Description') +api1 = swagger.docs( + Api(my_blueprint1), + apiVersion="0.1", + basePath="http://localhost:5000", + resourcePath="/", + produces=["application/json", "text/html"], + api_spec_url="/api/spec", + description="Blueprint1 Description", +) +api2 = swagger.docs( + Api(my_blueprint2), + apiVersion="0.1", + basePath="http://localhost:5000", + resourcePath="/", + produces=["application/json", "text/html"], + api_spec_url="/api/spec", + description="Blueprint2 Description", +) ################################### TODOS = { - 'todo1': {'task': 'build an API'}, - 'todo2': {'task': '?????'}, - 'todo3': {'task': 'profit!'}, + "todo1": {"task": "build an API"}, + "todo2": {"task": "?????"}, + "todo3": {"task": "profit!"}, } def abort_if_todo_doesnt_exist(todo_id): - if todo_id not in TODOS: - abort(404, message="Todo {} doesn't exist".format(todo_id)) + if todo_id not in TODOS: + abort(404, message="Todo {} doesn't exist".format(todo_id)) + parser = reqparse.RequestParser() -parser.add_argument('task', type=str) +parser.add_argument("task", type=str) @swagger.model class TodoItem: - """This is an example of a model class that has parameters in its constructor + """This is an example of a model class that has parameters in its constructor and the fields in the swagger spec are derived from the parameters to __init__. In this case we would have args, arg2 as required parameters and arg3 as optional parameter.""" - def __init__(self, arg1, arg2, arg3='123'): - pass + + def __init__(self, arg1, arg2, arg3="123"): + pass + class Todo(Resource): - "My TODO API" - @swagger.operation( - notes='get a todo item by ID', - nickname='get', - # Parameters can be automatically extracted from URLs (e.g. ) - # but you could also override them here, or add other parameters. - parameters=[ - { - "name": "todo_id_x", - "description": "The ID of the TODO item", - "required": True, - "allowMultiple": False, - "dataType": 'string', - "paramType": "path" - }, - { - "name": "a_bool", - "description": "The ID of the TODO item", - "required": True, - "allowMultiple": False, - "dataType": 'boolean', - "paramType": "path" - } - ]) - def get(self, todo_id): - # This goes into the summary - """Get a todo task + "My TODO API" + + @swagger.operation( + notes="get a todo item by ID", + nickname="get", + # Parameters can be automatically extracted from URLs. + # For Example: + # but you could also override them here, or add other parameters. + parameters=[ + { + "name": "todo_id_x", + "description": "The ID of the TODO item", + "required": True, + "allowMultiple": False, + "dataType": "string", + "paramType": "path", + }, + { + "name": "a_bool", + "description": "The ID of the TODO item", + "required": True, + "allowMultiple": False, + "dataType": "boolean", + "paramType": "path", + }, + ], + ) + def get(self, todo_id): + # This goes into the summary + """Get a todo task This will be added to the Implementation Notes. It let's you put very long text in your api. @@ -98,136 +110,143 @@ def get(self, todo_id): cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. """ - abort_if_todo_doesnt_exist(todo_id) - return TODOS[todo_id], 200, {'Access-Control-Allow-Origin': '*'} - - @swagger.operation( - notes='delete a todo item by ID', - ) - def delete(self, todo_id): - abort_if_todo_doesnt_exist(todo_id) - del TODOS[todo_id] - return '', 204, {'Access-Control-Allow-Origin': '*'} - - @swagger.operation( - notes='edit a todo item by ID', - ) - def put(self, todo_id): - args = parser.parse_args() - task = {'task': args['task']} - TODOS[todo_id] = task - return task, 201, {'Access-Control-Allow-Origin': '*'} - - def options (self, **args): - # since this method is not decorated with @swagger.operation it does not - # get added to the swagger docs - return {'Allow' : 'GET,PUT,POST,DELETE' }, 200, \ - { 'Access-Control-Allow-Origin': '*', \ - 'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE', \ - 'Access-Control-Allow-Headers': 'Content-Type' } + abort_if_todo_doesnt_exist(todo_id) + return TODOS[todo_id], 200, {"Access-Control-Allow-Origin": "*"} + + @swagger.operation(notes="delete a todo item by ID",) + def delete(self, todo_id): + abort_if_todo_doesnt_exist(todo_id) + del TODOS[todo_id] + return "", 204, {"Access-Control-Allow-Origin": "*"} + + @swagger.operation(notes="edit a todo item by ID",) + def put(self, todo_id): + args = parser.parse_args() + task = {"task": args["task"]} + TODOS[todo_id] = task + return task, 201, {"Access-Control-Allow-Origin": "*"} + + def options(self, **args): + # since this method is not decorated with @swagger.operation it does + # not get added to the swagger docs + return ( + {"Allow": "GET,PUT,POST,DELETE"}, + 200, + { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,PUT,POST,DELETE", + "Access-Control-Allow-Headers": "Content-Type", + }, + ) + # TodoList # shows a list of all todos, and lets you POST to add new tasks class TodoList(Resource): + def get(self): + return TODOS, 200, {"Access-Control-Allow-Origin": "*"} + + @swagger.operation( + notes="Creates a new TODO item", + responseClass=TodoItem.__name__, + nickname="create", + parameters=[ + { + "name": "body", + "description": "A TODO item", + "required": True, + "allowMultiple": False, + "dataType": TodoItem.__name__, + "paramType": "body", + } + ], + responseMessages=[ + { + "code": 201, + "message": "Created. The URL of the created blueprint should " + + "be in the Location header", + }, + {"code": 405, "message": "Invalid input"}, + ], + ) + def post(self): + args = parser.parse_args() + todo_id = "todo%d" % (len(TODOS) + 1) + TODOS[todo_id] = {"task": args["task"]} + return TODOS[todo_id], 201, {"Access-Control-Allow-Origin": "*"} - def get(self): - return TODOS, 200, {'Access-Control-Allow-Origin': '*'} - - @swagger.operation( - notes='Creates a new TODO item', - responseClass=TodoItem.__name__, - nickname='create', - parameters=[ - { - "name": "body", - "description": "A TODO item", - "required": True, - "allowMultiple": False, - "dataType": TodoItem.__name__, - "paramType": "body" - } - ], - responseMessages=[ - { - "code": 201, - "message": "Created. The URL of the created blueprint should " + - "be in the Location header" - }, - { - "code": 405, - "message": "Invalid input" - } - ]) - def post(self): - args = parser.parse_args() - todo_id = 'todo%d' % (len(TODOS) + 1) - TODOS[todo_id] = {'task': args['task']} - return TODOS[todo_id], 201, {'Access-Control-Allow-Origin': '*'} @swagger.model class ModelWithResourceFields: - resource_fields = { - 'a_string': fields.String() - } + resource_fields = {"a_string": fields.String()} + @swagger.model @swagger.nested( - a_nested_attribute=ModelWithResourceFields.__name__, - a_list_of_nested_types=ModelWithResourceFields.__name__) + a_nested_attribute=ModelWithResourceFields.__name__, + a_list_of_nested_types=ModelWithResourceFields.__name__, +) class TodoItemWithResourceFields: - """This is an example of how Output Fields work + """This is an example of how Output Fields work (http://flask-restful.readthedocs.org/en/latest/fields.html). Output Fields lets you add resource_fields to your model in which you specify the output of the model when it gets sent as an HTTP response. flask-restful-swagger takes advantage of this to specify the fields in the model""" - resource_fields = { - 'a_string': fields.String(attribute='a_string_field_name'), - 'a_formatted_string': fields.FormattedString, - 'an_int': fields.Integer, - 'a_bool': fields.Boolean, - 'a_url': fields.Url, - 'a_float': fields.Float, - 'an_float_with_arbitrary_precision': fields.Arbitrary, - 'a_fixed_point_decimal': fields.Fixed, - 'a_datetime': fields.DateTime, - 'a_list_of_strings': fields.List(fields.String), - 'a_nested_attribute': fields.Nested(ModelWithResourceFields.resource_fields), - 'a_list_of_nested_types': fields.List(fields.Nested(ModelWithResourceFields.resource_fields)), - } - - # Specify which of the resource fields are required - required = ['a_string'] + + resource_fields = { + "a_string": fields.String(attribute="a_string_field_name"), + "a_formatted_string": fields.FormattedString, + "an_int": fields.Integer, + "a_bool": fields.Boolean, + "a_url": fields.Url, + "a_float": fields.Float, + "an_float_with_arbitrary_precision": fields.Arbitrary, + "a_fixed_point_decimal": fields.Fixed, + "a_datetime": fields.DateTime, + "a_list_of_strings": fields.List(fields.String), + "a_nested_attribute": fields.Nested( + ModelWithResourceFields.resource_fields + ), + "a_list_of_nested_types": fields.List( + fields.Nested(ModelWithResourceFields.resource_fields) + ), + } + + # Specify which of the resource fields are required + required = ["a_string"] + class MarshalWithExample(Resource): - @swagger.operation( - notes='get something', - responseClass=TodoItemWithResourceFields, - nickname='get') - @marshal_with(TodoItemWithResourceFields.resource_fields) - def get(self, **kwargs): - return {}, 200, {'Access-Control-Allow-Origin': '*'} - - -## -## Actually setup the Api resource routing here -## -api1.add_resource(TodoList, '/todos1') -api1.add_resource(Todo, '/todos1/') -api1.add_resource(MarshalWithExample, '/marshal_with1') -api2.add_resource(TodoList, '/todos2') -api2.add_resource(Todo, '/todos2/') -api2.add_resource(MarshalWithExample, '/marshal_with2') - - -@app.route('/docs') + @swagger.operation( + notes="get something", + responseClass=TodoItemWithResourceFields, + nickname="get", + ) + @marshal_with(TodoItemWithResourceFields.resource_fields) + def get(self, **kwargs): + return {}, 200, {"Access-Control-Allow-Origin": "*"} + + +# +# Actually setup the Api resource routing here +# +api1.add_resource(TodoList, "/todos1") +api1.add_resource(Todo, "/todos1/") +api1.add_resource(MarshalWithExample, "/marshal_with1") +api2.add_resource(TodoList, "/todos2") +api2.add_resource(Todo, "/todos2/") +api2.add_resource(MarshalWithExample, "/marshal_with2") + + +@app.route("/docs") def docs(): - return redirect('/static/docs.html') + return redirect("/static/docs.html") -if __name__ == '__main__': - TodoItemWithResourceFields() - TodoItem(1, 2, '3') - app.register_blueprint(my_blueprint1, url_prefix='/api1') - app.register_blueprint(my_blueprint2, url_prefix='/api2') - app.run(debug=True) +if __name__ == "__main__": + TodoItemWithResourceFields() + TodoItem(1, 2, "3") + app.register_blueprint(my_blueprint1, url_prefix="/api1") + app.register_blueprint(my_blueprint2, url_prefix="/api2") + app.run(host='0.0.0.0', debug=True) diff --git a/examples/inheritance.py b/examples/inheritance.py index c6ccaee..81fc17f 100644 --- a/examples/inheritance.py +++ b/examples/inheritance.py @@ -1,62 +1,68 @@ -''' +""" Running: PYTHONPATH=. python examples/inheritance.py -''' +""" from flask import Flask from flask_restful import Api, Resource + from flask_restful_swagger import swagger -app = Flask(__name__, static_folder='../static') +app = Flask(__name__, static_folder="../static") ################################### # This is important: -api = swagger.docs(Api(app), apiVersion='0.1', - basePath='http://localhost:5000', - resourcePath='/', - produces=["application/json", "text/html"], - api_spec_url='/api/spec', - description='Inheritance demonstration') +api = swagger.docs( + Api(app), + apiVersion="0.1", + basePath="http://localhost:5000", + resourcePath="/", + produces=["application/json", "text/html"], + api_spec_url="/api/spec", + description="Inheritance demonstration", +) ################################### class Base(Resource): - def get(self): - pass + def get(self): + pass - def post(self): - pass + def post(self): + pass - def delete(self): - pass + def delete(self): + pass class Inherited(Base): - @swagger.operation( - notes='just testing inheritance', - nickname='get', - parameters=[ - { - "name": "a_bool", - "description": "The ID of the TODO item", - "required": True, - "allowMultiple": False, - "dataType": 'boolean', - "paramType": "path" - } - ]) - def get(self): - return "hello" - - def post(self): - # wont be visible in the swagger docs - return "world" - -## -## Actually setup the Api resource routing here -## -api.add_resource(Inherited, '/inherited') - -if __name__ == '__main__': - app.run(debug=True) + @swagger.operation( + notes="just testing inheritance", + nickname="get", + parameters=[ + { + "name": "a_bool", + "description": "The ID of the TODO item", + "required": True, + "allowMultiple": False, + "dataType": "boolean", + "paramType": "path", + } + ], + ) + def get(self): + return "hello" + + def post(self): + # wont be visible in the swagger docs + return "world" + + +# +# Actually setup the Api resource routing here +# +api.add_resource(Inherited, "/inherited") + +if __name__ == "__main__": + app.run(host='0.0.0.0', debug=True) diff --git a/flask_restful_swagger/__init__.py b/flask_restful_swagger/__init__.py index 5a3a67a..9f32af6 100644 --- a/flask_restful_swagger/__init__.py +++ b/flask_restful_swagger/__init__.py @@ -1,5 +1,3 @@ -registry = { - 'models': {} -} +registry = {"models": {}} -api_spec_static = '' +api_spec_static = "" diff --git a/flask_restful_swagger/container_boot.sh b/flask_restful_swagger/container_boot.sh new file mode 100755 index 0000000..892aca7 --- /dev/null +++ b/flask_restful_swagger/container_boot.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +pushd "flask_restful_swagger" || exit 127 +while true; do sleep 1; done diff --git a/flask_restful_swagger/swagger.py b/flask_restful_swagger/swagger.py index f9ead0f..b97672e 100644 --- a/flask_restful_swagger/swagger.py +++ b/flask_restful_swagger/swagger.py @@ -2,443 +2,487 @@ import inspect import os import re + import six +from flask import Response, abort, request +from flask_restful import Resource, fields +from jinja2 import Template + +from flask_restful_swagger import api_spec_static, registry try: # urlparse is renamed to urllib.parse in python 3 import urlparse -except ImportError: +except ImportError: # no cover from urllib import parse as urlparse - -from flask import request, abort, Response -from flask_restful import Resource, fields -from flask_restful_swagger import ( - registry, api_spec_static) -from jinja2 import Template - resource_listing_endpoint = None -def docs(api, apiVersion='0.0', swaggerVersion='1.2', - basePath='http://localhost:5000', - resourcePath='/', - produces=["application/json"], - api_spec_url='/api/spec', - description='Auto generated API docs by flask-restful-swagger'): +def docs( + api, + apiVersion="0.0", + swaggerVersion="1.2", + basePath="http://localhost:5000", + resourcePath="/", + produces=["application/json"], + api_spec_url="/api/spec", + description="Auto generated API docs by flask-restful-swagger", +): + + api_add_resource = api.add_resource - api_add_resource = api.add_resource + def add_resource(resource, *urls, **kvargs): + register_once( + api, + api_add_resource, + apiVersion, + swaggerVersion, + basePath, + resourcePath, + produces, + api_spec_url, + description, + ) - def add_resource(resource, *urls, **kvargs): - register_once(api, api_add_resource, apiVersion, swaggerVersion, basePath, - resourcePath, produces, api_spec_url, description) + resource = make_class(resource) + for url in urls: + endpoint = swagger_endpoint(api, resource, url) - resource = make_class(resource) - for url in urls: - endpoint = swagger_endpoint(api, resource, url) + # Add a .help.json help url + swagger_path = extract_swagger_path(url) - # Add a .help.json help url - swagger_path = extract_swagger_path(url) + # Add a .help.html help url + endpoint_html_str = "{0}/help".format(swagger_path) + api_add_resource( + endpoint, + "{0}.help.json".format(swagger_path), + "{0}.help.html".format(swagger_path), + endpoint=endpoint_html_str, + ) - # Add a .help.html help url - endpoint_html_str = '{0}/help'.format(swagger_path) - api_add_resource( - endpoint, - "{0}.help.json".format(swagger_path), - "{0}.help.html".format(swagger_path), - endpoint=endpoint_html_str) + return api_add_resource(resource, *urls, **kvargs) - return api_add_resource(resource, *urls, **kvargs) + api.add_resource = add_resource - api.add_resource = add_resource + return api - return api rootPath = os.path.dirname(__file__) def make_class(class_or_instance): - if inspect.isclass(class_or_instance): - return class_or_instance - return class_or_instance.__class__ - - -def register_once(api, add_resource_func, apiVersion, swaggerVersion, basePath, - resourcePath, produces, endpoint_path, description): - global api_spec_static - global resource_listing_endpoint - - if api.blueprint and not registry.get(api.blueprint.name): - # Most of all this can be taken from the blueprint/app - registry[api.blueprint.name] = { - 'apiVersion': apiVersion, - 'swaggerVersion': swaggerVersion, - 'basePath': basePath, - 'spec_endpoint_path': endpoint_path, - 'resourcePath': resourcePath, - 'produces': produces, - 'x-api-prefix': '', - 'apis': [], - 'description': description - } + if inspect.isclass(class_or_instance): + return class_or_instance + return class_or_instance.__class__ + + +def register_once( + api, + add_resource_func, + apiVersion, + swaggerVersion, + basePath, + resourcePath, + produces, + endpoint_path, + description, +): + global api_spec_static + global resource_listing_endpoint + + if api.blueprint and not registry.get(api.blueprint.name): + # Most of all this can be taken from the blueprint/app + registry[api.blueprint.name] = { + "apiVersion": apiVersion, + "swaggerVersion": swaggerVersion, + "basePath": basePath, + "spec_endpoint_path": endpoint_path, + "resourcePath": resourcePath, + "produces": produces, + "x-api-prefix": "", + "apis": [], + "description": description, + } - def registering_blueprint(setup_state): - reg = registry[setup_state.blueprint.name] - reg['x-api-prefix'] = setup_state.url_prefix - - api.blueprint.record(registering_blueprint) - - add_resource_func( - SwaggerRegistry, - endpoint_path, - endpoint_path + '.json', - endpoint_path + '.html' - ) - - resource_listing_endpoint = endpoint_path + '/_/resource_list.json' - add_resource_func(ResourceLister, resource_listing_endpoint) - - api_spec_static = endpoint_path + '/_/static/' - add_resource_func( - StaticFiles, - api_spec_static + '//', - api_spec_static + '/', - api_spec_static + '') - elif not 'app' in registry: - registry['app'] = { - 'apiVersion': apiVersion, - 'swaggerVersion': swaggerVersion, - 'basePath': basePath, - 'spec_endpoint_path': endpoint_path, - 'resourcePath': resourcePath, - 'produces': produces, - 'description': description - } + def registering_blueprint(setup_state): + reg = registry[setup_state.blueprint.name] + reg["x-api-prefix"] = setup_state.url_prefix + + api.blueprint.record(registering_blueprint) + + add_resource_func( + SwaggerRegistry, + endpoint_path, + endpoint_path + ".json", + endpoint_path + ".html", + ) + + resource_listing_endpoint = endpoint_path + "/_/resource_list.json" + add_resource_func(ResourceLister, resource_listing_endpoint) + + api_spec_static = endpoint_path + "/_/static/" + add_resource_func( + StaticFiles, + api_spec_static + "//", + api_spec_static + "/", + api_spec_static + "", + ) + elif "app" not in registry: + registry["app"] = { + "apiVersion": apiVersion, + "swaggerVersion": swaggerVersion, + "basePath": basePath, + "spec_endpoint_path": endpoint_path, + "resourcePath": resourcePath, + "produces": produces, + "description": description, + } + + add_resource_func( + SwaggerRegistry, + endpoint_path, + endpoint_path + ".json", + endpoint_path + ".html", + endpoint="app/registry", + ) + + resource_listing_endpoint = endpoint_path + "/_/resource_list.json" + add_resource_func( + ResourceLister, + resource_listing_endpoint, + endpoint="app/resourcelister", + ) + + api_spec_static = endpoint_path + "/_/static/" + add_resource_func( + StaticFiles, + api_spec_static + "//", + api_spec_static + "/", + api_spec_static + "", + endpoint="app/staticfiles", + ) - add_resource_func( - SwaggerRegistry, - endpoint_path, - endpoint_path + '.json', - endpoint_path + '.html', - endpoint='app/registry' - ) - - resource_listing_endpoint = endpoint_path + '/_/resource_list.json' - add_resource_func( - ResourceLister, resource_listing_endpoint, - endpoint='app/resourcelister') - - api_spec_static = endpoint_path + '/_/static/' - add_resource_func( - StaticFiles, - api_spec_static + '//', - api_spec_static + '/', - api_spec_static + '', - endpoint='app/staticfiles') templates = {} def render_endpoint(endpoint): - return render_page("endpoint.html", endpoint.__dict__) + return render_page("endpoint.html", endpoint.__dict__) def render_homepage(resource_list_url): - conf = {'resource_list_url': resource_list_url} - return render_page("index.html", conf) + conf = {"resource_list_url": resource_list_url} + return render_page("index.html", conf) def _get_current_registry(api=None): - # import ipdb;ipdb.set_trace() - global registry - app_name = None - overrides = {} - if api: - app_name = api.blueprint.name if api.blueprint else None - else: - app_name = request.blueprint - urlparts = urlparse.urlparse(request.url_root.rstrip('/')) - proto = request.headers.get("x-forwarded-proto") or urlparts[0] - overrides = {'basePath': urlparse.urlunparse([proto] + list(urlparts[1:]))} + # import ipdb;ipdb.set_trace() + global registry + app_name = None + overrides = {} + if api: + app_name = api.blueprint.name if api.blueprint else None + else: + app_name = request.blueprint + urlparts = urlparse.urlparse(request.url_root.rstrip("/")) + proto = request.headers.get("x-forwarded-proto") or urlparts[0] + overrides = { + "basePath": urlparse.urlunparse([proto] + list(urlparts[1:])), + } - if not app_name: - app_name = 'app' + if not app_name: + app_name = "app" - overrides['models'] = registry.get('models', {}) + overrides["models"] = registry.get("models", {}) - reg = registry.setdefault(app_name, {}) - reg.update(overrides) + reg = registry.setdefault(app_name, {}) + reg.update(overrides) - reg['basePath'] = reg['basePath'] + (reg.get('x-api-prefix', '') or '') + reg["basePath"] = reg["basePath"] + (reg.get("x-api-prefix", "") or "") - return reg + return reg def render_page(page, info): - req_registry = _get_current_registry() - url = req_registry['basePath'] - if url.endswith('/'): - url = url.rstrip('/') - conf = { - 'base_url': url + api_spec_static, - 'full_base_url': url + api_spec_static - } - if info is not None: - conf.update(info) - global templates - if page in templates: - template = templates[page] - else: - with open(os.path.join(rootPath, 'static', page), "r") as fs: - template = Template(fs.read()) - templates[page] = template - mime = 'text/html' - if page.endswith('.js'): - mime = 'text/javascript' - return Response(template.render(conf), mimetype=mime) + req_registry = _get_current_registry() + url = req_registry["basePath"] + if url.endswith("/"): + url = url.rstrip("/") + conf = { + "base_url": url + api_spec_static, + "full_base_url": url + api_spec_static, + } + if info is not None: + conf.update(info) + global templates + if page in templates: + template = templates[page] + else: + with open(os.path.join(rootPath, "static", page), "r") as fs: + template = Template(fs.read()) + templates[page] = template + mime = "text/html" + if page.endswith(".js"): + mime = "text/javascript" + return Response(template.render(conf), mimetype=mime) class StaticFiles(Resource): + def get(self, dir1=None, dir2=None, dir3=None): + req_registry = _get_current_registry() - def get(self, dir1=None, dir2=None, dir3=None): - req_registry = _get_current_registry() - - if dir1 is None: - filePath = "index.html" - else: - filePath = dir1 - if dir2 is not None: - filePath = "%s/%s" % (filePath, dir2) - if dir3 is not None: - filePath = "%s/%s" % (filePath, dir3) - if filePath in [ - "index.html", "o2c.html", "swagger-ui.js", - "swagger-ui.min.js", "lib/swagger-oauth.js"]: - conf = {'resource_list_url': req_registry['spec_endpoint_path']} - return render_page(filePath, conf) - mime = 'text/plain' - if filePath.endswith(".gif"): - mime = 'image/gif' - elif filePath.endswith(".png"): - mime = 'image/png' - elif filePath.endswith(".js"): - mime = 'text/javascript' - elif filePath.endswith(".css"): - mime = 'text/css' - filePath = os.path.join(rootPath, 'static', filePath) - if os.path.exists(filePath): - fs = open(filePath, "rb") - return Response(fs, mimetype=mime) - abort(404) + if dir1 is None: + filePath = "index.html" + else: + filePath = dir1 + if dir2 is not None: + filePath = "%s/%s" % (filePath, dir2) + if dir3 is not None: + filePath = "%s/%s" % (filePath, dir3) + if filePath in [ + "index.html", + "o2c.html", + "swagger-ui.js", + "swagger-ui.min.js", + "lib/swagger-oauth.js", + ]: + conf = {"resource_list_url": req_registry["spec_endpoint_path"]} + return render_page(filePath, conf) + mime = "text/plain" + if filePath.endswith(".gif"): + mime = "image/gif" + elif filePath.endswith(".png"): + mime = "image/png" + elif filePath.endswith(".js"): + mime = "text/javascript" + elif filePath.endswith(".css"): + mime = "text/css" + filePath = os.path.join(rootPath, "static", filePath) + if os.path.exists(filePath): + fs = open(filePath, "rb") + return Response(fs, mimetype=mime) + abort(404) class ResourceLister(Resource): - def get(self): - req_registry = _get_current_registry() - return { - "apiVersion": req_registry['apiVersion'], - "swaggerVersion": req_registry['swaggerVersion'], - "apis": [ - { - "path": ( - req_registry['basePath'] + req_registry['spec_endpoint_path']), - "description": req_registry['description'] + def get(self): + req_registry = _get_current_registry() + return { + "apiVersion": + req_registry["apiVersion"], + "swaggerVersion": + req_registry["swaggerVersion"], + "apis": [{ + "path": (req_registry["basePath"] + + req_registry["spec_endpoint_path"]), + "description": + req_registry["description"], + }], } - ] - } def swagger_endpoint(api, resource, path): - endpoint = SwaggerEndpoint(resource, path) - req_registry = _get_current_registry(api=api) - req_registry.setdefault('apis', []).append(endpoint.__dict__) + endpoint = SwaggerEndpoint(resource, path) + req_registry = _get_current_registry(api=api) + req_registry.setdefault("apis", []).append(endpoint.__dict__) - class SwaggerResource(Resource): - def get(self): - if request.path.endswith('.help.json'): - return endpoint.__dict__ - if request.path.endswith('.help.html'): - return render_endpoint(endpoint) - return SwaggerResource + class SwaggerResource(Resource): + def get(self): + if request.path.endswith(".help.json"): + return endpoint.__dict__ + if request.path.endswith(".help.html"): + return render_endpoint(endpoint) + + return SwaggerResource def _sanitize_doc(comment): - return comment.replace('\n', '
') if comment else comment + return comment.replace("\n", "
") if comment else comment def _parse_doc(obj): - first_line, other_lines = None, None - - full_doc = inspect.getdoc(obj) - if full_doc: - line_feed = full_doc.find('\n') - if line_feed != -1: - first_line = _sanitize_doc(full_doc[:line_feed]) - other_lines = _sanitize_doc(full_doc[line_feed+1:]) - else: - first_line = full_doc + first_line, other_lines = None, None + + full_doc = inspect.getdoc(obj) + if full_doc: + line_feed = full_doc.find("\n") + if line_feed != -1: + first_line = _sanitize_doc(full_doc[:line_feed]) + other_lines = _sanitize_doc(full_doc[line_feed + 1:]) + else: + first_line = full_doc - return first_line, other_lines + return first_line, other_lines class SwaggerEndpoint(object): - def __init__(self, resource, path): - self.path = extract_swagger_path(path) - path_arguments = extract_path_arguments(path) - self.description, self.notes = _parse_doc(resource) - self.operations = self.extract_operations(resource, path_arguments) - - @staticmethod - def extract_operations(resource, path_arguments=[]): - operations = [] - for method in [m.lower() for m in resource.methods]: - method_impl = resource.__dict__.get(method, None) - if method_impl is None: - for cls in resource.__mro__: - for item_key in cls.__dict__.keys(): - if item_key == method: - method_impl = cls.__dict__[item_key] - op = { - 'method': method, - 'parameters': path_arguments, - 'nickname': 'nickname' - } - op['summary'], op['notes'] = _parse_doc(method_impl) - - if '__swagger_attr' in method_impl.__dict__: - # This method was annotated with @swagger.operation - decorators = method_impl.__dict__['__swagger_attr'] - for att_name, att_value in list(decorators.items()): - if isinstance(att_value, six.string_types + (int, list)): - if att_name == 'parameters': - op['parameters'] = merge_parameter_list( - op['parameters'], att_value) - else: - if op.get(att_name) and att_name is not 'nickname': - att_value = '{0}
{1}'.format(att_value, op[att_name]) - op[att_name] = att_value - elif isinstance(att_value, object): - op[att_name] = att_value.__name__ - operations.append(op) - return operations + def __init__(self, resource, path): + self.path = extract_swagger_path(path) + path_arguments = extract_path_arguments(path) + self.description, self.notes = _parse_doc(resource) + self.operations = self.extract_operations(resource, path_arguments) + + @staticmethod + def extract_operations(resource, path_arguments=[]): + operations = [] + for method in [m.lower() for m in resource.methods]: + method_impl = resource.__dict__.get(method, None) + if method_impl is None: + for cls in resource.__mro__: + for item_key in cls.__dict__.keys(): + if item_key == method: + method_impl = cls.__dict__[item_key] + op = { + "method": method, + "parameters": path_arguments, + "nickname": "nickname", + } + op["summary"], op["notes"] = _parse_doc(method_impl) + + if "__swagger_attr" in method_impl.__dict__: + # This method was annotated with @swagger.operation + decorators = method_impl.__dict__["__swagger_attr"] + for att_name, att_value in list(decorators.items()): + if isinstance(att_value, six.string_types + (int, list)): + if att_name == "parameters": + op["parameters"] = merge_parameter_list( + op["parameters"], att_value) + else: + if op.get(att_name) and att_name != "nickname": + att_value = "{0}
{1}".format( + att_value, op[att_name]) + op[att_name] = att_value + elif isinstance(att_value, object): # no cover + op[att_name] = att_value.__name__ + operations.append(op) + return operations def merge_parameter_list(base, override): - base = list(base) - names = [x['name'] for x in base] - for o in override: - if o['name'] in names: - for n, i in enumerate(base): - if i['name'] == o['name']: - base[n] = o - else: - base.append(o) - return base + base = list(base) + names = [x["name"] for x in base] + for o in override: + if o["name"] in names: + for n, i in enumerate(base): + if i["name"] == o["name"]: + base[n] = o + else: + base.append(o) + return base class SwaggerRegistry(Resource): - def get(self): - req_registry = _get_current_registry() - if request.path.endswith('.html'): - return render_homepage( - req_registry['basePath'] + req_registry['spec_endpoint_path'] + '/_/resource_list.json') - return req_registry + def get(self): + req_registry = _get_current_registry() + if request.path.endswith(".html"): + return render_homepage(req_registry["basePath"] + + req_registry["spec_endpoint_path"] + + "/_/resource_list.json") + return req_registry def operation(**kwargs): - """ - This dedorator marks a function as a swagger operation so that we can easily + """ + This decorator marks a function as a swagger operation so that we can easily extract attributes from it. It saves the decorator's key-values at the function level so we can later extract them later when add_resource is invoked. """ - def inner(f): - f.__swagger_attr = kwargs - return f - return inner + def inner(f): + f.__swagger_attr = kwargs + return f + + return inner def model(c=None, *args, **kwargs): - add_model(c) - return c + add_model(c) + return c class _Nested(object): - def __init__(self, klass, **kwargs): - self._nested = kwargs - self._klass = klass + def __init__(self, klass, **kwargs): + self._nested = kwargs + self._klass = klass - def __call__(self, *args, **kwargs): - return self._klass(*args, **kwargs) + def __call__(self, *args, **kwargs): + return self._klass(*args, **kwargs) - def nested(self): - return self._nested + def nested(self): + return self._nested # wrap _Cache to allow for deferred calling def nested(klass=None, **kwargs): - if klass: - ret = _Nested(klass) - functools.update_wrapper(ret, klass) - else: - def wrapper(klass): - wrapped = _Nested(klass, **kwargs) - functools.update_wrapper(wrapped, klass) - return wrapped - ret = wrapper - return ret + if klass: + ret = _Nested(klass) + functools.update_wrapper(ret, klass) + else: + + def wrapper(klass): + wrapped = _Nested(klass, **kwargs) + functools.update_wrapper(wrapped, klass) + return wrapped + + ret = wrapper + return ret def add_model(model_class): - models = registry['models'] - name = model_class.__name__ - model = models[name] = {'id': name} - model['description'], model['notes'] = _parse_doc(model_class) - if 'resource_fields' in dir(model_class): - # We take special care when the model class has a field resource_fields. - # By convension this field specifies what flask-restful would return when - # this model is used as a return value from an HTTP endpoint. - # We look at the class and search for an attribute named - # resource_fields. - # If that attribute exists then we deduce the swagger model by the content - # of this attribute - - if hasattr(model_class, 'required'): - required = model['required'] = model_class.required - - properties = model['properties'] = {} - nested = model_class.nested() if isinstance(model_class, _Nested) else {} - for field_name, field_type in list(model_class.resource_fields.items()): - nested_type = nested[field_name] if field_name in nested else None - properties[field_name] = deduce_swagger_type(field_type, nested_type) - elif '__init__' in dir(model_class): - # Alternatively, if a resource_fields does not exist, we deduce the model - # fields from the parameters sent to its __init__ method - - # Credits for this snippet go to Robin Walsh - # https://github.com/hobbeswalsh/flask-sillywalk - argspec = inspect.getargspec(model_class.__init__) - argspec.args.remove("self") - defaults = {} - required = model['required'] = [] - if argspec.defaults: - defaults = list( - zip(argspec.args[-len(argspec.defaults):], argspec.defaults)) - properties = model['properties'] = {} - required_args_count = len(argspec.args) - len(defaults) - for arg in argspec.args[:required_args_count]: - required.append(arg) - # type: string for lack of better knowledge, until we add more metadata - properties[arg] = {'type': 'string'} - for k, v in defaults: - properties[k] = {'type': 'string', "default": v} - - if 'swagger_metadata' in dir(model_class): - for field_name, field_metadata in model_class.swagger_metadata.items(): - if field_name in properties: - # does not work for Python 3.x; see: http://stackoverflow.com/questions/38987/how-can-i-merge-two-python-dictionaries-in-a-single-expression - # properties[field_name] = dict(properties[field_name].items() + field_metadata.items()) - properties[field_name].update(field_metadata) + models = registry["models"] + name = model_class.__name__ + model = models[name] = {"id": name} + model["description"], model["notes"] = _parse_doc(model_class) + if "resource_fields" in dir(model_class): + # We take special care when a model class has a field resource_fields. + # By convention this field specifies what flask-restful would return + # when this model is used as a return value from an HTTP endpoint. + # We look at the class and search for an attribute named + # resource_fields. + # If that attribute exists then we deduce the swagger model by the + # content of this attribute + + if hasattr(model_class, "required"): + required = model["required"] = model_class.required + + properties = model["properties"] = {} + nested = (model_class.nested() + if isinstance(model_class, _Nested) else {}) + for field_name, field_type in list( + model_class.resource_fields.items()): + nested_type = nested[field_name] if field_name in nested else None + properties[field_name] = deduce_swagger_type( + field_type, nested_type) + elif "__init__" in dir(model_class): + # Alternatively, if a resource_fields does not exist, we deduce the + # model fields from the parameters sent to its __init__ method + + # Credits for this snippet go to Robin Walsh + # https://github.com/hobbeswalsh/flask-sillywalk + argspec = inspect.getargspec(model_class.__init__) + argspec.args.remove("self") + defaults = {} + required = model["required"] = [] + if argspec.defaults: + defaults = list( + zip(argspec.args[-len(argspec.defaults):], argspec.defaults)) + properties = model["properties"] = {} + required_args_count = len(argspec.args) - len(defaults) + for arg in argspec.args[:required_args_count]: # type: str + # str for lack of better knowledge, until we add more metadata + required.append(arg) + properties[arg] = {"type": "string"} + for k, v in defaults: + properties[k] = {"type": "string", "default": v} + + if "swagger_metadata" in dir(model_class): + for field_name, field_metadata in model_class.swagger_metadata.items(): + if field_name in properties: + # does not work for Python 3.x; see: http://stackoverflow.com/questions/38987/how-can-i-merge-two-python-dictionaries-in-a-single-expression # noqa + # properties[field_name] = dict(properties[field_name].items() + field_metadata.items()) # noqa + properties[field_name].update(field_metadata) + def deduce_swagger_type(python_type_or_object, nested_type=None): import inspect @@ -447,75 +491,92 @@ def deduce_swagger_type(python_type_or_object, nested_type=None): predicate = issubclass else: predicate = isinstance - if predicate(python_type_or_object, six.string_types + ( - fields.String, - fields.FormattedString, - fields.Url, - int, - fields.Integer, - float, - fields.Float, - fields.Arbitrary, - fields.Fixed, - bool, - fields.Boolean, - fields.DateTime)): - return {'type': deduce_swagger_type_flat(python_type_or_object)} + if predicate( + python_type_or_object, + six.string_types + ( + fields.String, + fields.FormattedString, + fields.Url, + int, + fields.Integer, + float, + fields.Float, + fields.Arbitrary, + fields.Fixed, + bool, + fields.Boolean, + fields.DateTime, + ), + ): + return {"type": deduce_swagger_type_flat(python_type_or_object)} if predicate(python_type_or_object, (fields.List)): if inspect.isclass(python_type_or_object): - return {'type': 'array'} + return {"type": "array"} else: - return {'type': 'array', - 'items': { - '$ref': deduce_swagger_type_flat( - python_type_or_object.container, nested_type)}} + return { + "type": "array", + "items": { + "$ref": + deduce_swagger_type_flat(python_type_or_object.container, + nested_type) + }, + } if predicate(python_type_or_object, (fields.Nested)): - return {'type': nested_type} + return {"type": nested_type} - return {'type': 'null'} + return {"type": "null"} def deduce_swagger_type_flat(python_type_or_object, nested_type=None): if nested_type: - return nested_type + return nested_type import inspect if inspect.isclass(python_type_or_object): predicate = issubclass else: predicate = isinstance - if predicate(python_type_or_object, six.string_types + ( - fields.String, - fields.FormattedString, - fields.Url)): - return 'string' - if predicate(python_type_or_object, (int, - fields.Integer)): - return 'integer' - if predicate(python_type_or_object, (float, - fields.Float, - fields.Arbitrary, - fields.Fixed)): - return 'number' - if predicate(python_type_or_object, (bool, - fields.Boolean)): - return 'boolean' - if predicate(python_type_or_object, (fields.DateTime,)): - return 'date-time' + if predicate( + python_type_or_object, + six.string_types + ( + fields.String, + fields.FormattedString, + fields.Url, + ), + ): + return "string" + if predicate(python_type_or_object, (bool, fields.Boolean)): + return "boolean" + if predicate(python_type_or_object, (int, fields.Integer)): + return "integer" + # yapf: disable + if predicate( + python_type_or_object, + ( + float, + fields.Float, + fields.Arbitrary, + fields.Fixed, + ), + ): + return "number" + # yapf: enable + if predicate(python_type_or_object, (fields.DateTime)): + return "date-time" def extract_swagger_path(path): - """ + """ Extracts a swagger type path from the given flask style path. This /path/ turns into this /path/{parameter} And this /// to this: /{lang_code}/{id}/{probability} """ - return re.sub('<(?:[^:]+:)?([^>]+)>', '{\\1}', path) + return re.sub("<(?:[^:]+:)?([^>]+)>", "{\\1}", path) def extract_path_arguments(path): - """ + """ Extracts a swagger path arguments from the given flask path. This /path/ extracts [{name: 'parameter'}] And this /// @@ -524,19 +585,15 @@ def extract_path_arguments(path): {name: 'id', dataType: 'string'} {name: 'probability', dataType: 'float'}] """ - # Remove all paranteses - path = re.sub('\([^\)]*\)', '', path) - args = re.findall('<([^>]+)>', path) - - def split_arg(arg): - spl = arg.split(':') - if len(spl) == 1: - return {'name': spl[0], - 'dataType': 'string', - 'paramType': 'path'} - else: - return {'name': spl[1], - 'dataType': spl[0], - 'paramType': 'path'} + # Remove all parentheses + path = re.sub(r"\([^)]*\)", "", path) + args = re.findall("<([^>]+)>", path) + + def split_arg(arg): + spl = arg.split(":") + if len(spl) == 1: + return {"name": spl[0], "dataType": "string", "paramType": "path"} + else: + return {"name": spl[1], "dataType": spl[0], "paramType": "path"} - return list(map(split_arg, args)) + return list(map(split_arg, args)) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..eea2c18 --- /dev/null +++ b/pytest.ini @@ -0,0 +1 @@ +[pytest] diff --git a/scripts/commander.sh b/scripts/commander.sh new file mode 100755 index 0000000..ae848b4 --- /dev/null +++ b/scripts/commander.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +set -e + +PROJECT_HOME="$(git rev-parse --show-toplevel)" +PROJECT_NAME="flask_restful_swagger" +export PROJECT_HOME +export PROJECT_NAME + +# shellcheck source=scripts/common/common.sh +source "$( dirname "${BASH_SOURCE[0]}" )/common/common.sh" + +# Optional For Libraries +# shellcheck source=scripts/common/wheel.sh +# source "$( dirname "${BASH_SOURCE[0]}" )/common/wheel.sh" + +# Add Additional Functionality Via Imports Here + +case $1 in + 'lint') + shift + source_enviroment + lint "$@" + ;; + 'lint-validate') + shift + source_enviroment + lint_check "$@" + ;; + 'reinstall-requirements') + shift + source_enviroment + reinstall_requirements "$@" + ;; + 'sectest') + shift + source_enviroment + security "$@" + ;; + 'setup') + shift + setup_bash "$@" + setup_python "$@" + ;; + 'shortlist') + echo "lint lint-validate reinstall-requirements sectest setup test test-coverage" + ;; + 'test') + shift + source_enviroment + unittests "$@" + ;; + 'test-coverage') + shift + source_enviroment + unittests "coverage" "$@" + ;; + *) + echo "Valid Commands:" + echo ' - lint (Run the linter)' + echo ' - lint-validate (Validate linting)' + echo ' - reinstall-requirements (Reinstall Packages' + echo ' - sectest (Run security tests)' + echo ' - setup (Setup/Reset environment)' + echo ' - test (Run pytest)' + echo ' - test-coverage (Run pytest with coverage)' + ;; + +esac diff --git a/scripts/common/common.sh b/scripts/common/common.sh new file mode 100644 index 0000000..eb8b8a0 --- /dev/null +++ b/scripts/common/common.sh @@ -0,0 +1,144 @@ +#!/bin/bash + +# Do Not Modify This File, It's Intended To Be Updated From Time to TIme +# INSTEAD: add additional functionality by adding separate library files +# Import your new libraries into the commander.sh script and add them to the CLI. + +lint() { + + set -e + + pushd "${PROJECT_HOME}" > /dev/null + yapf -i --recursive --style=pep8 "${PROJECT_NAME}/" + isort -y + popd > /dev/null + + lint_check + +} + +lint_check() { + + set -e + + pushd "${PROJECT_HOME}" > /dev/null + isort -c + flake8 + shellcheck -x scripts/*.sh + shellcheck -x scripts/common/*.sh + popd > /dev/null + +} + +reinstall_requirements() { + + set -e + + pushd "${PROJECT_HOME}" > /dev/null + pip install -r assets/requirements.txt --no-warn-script-location + pip install -r assets/requirements-dev.txt --no-warn-script-location + popd > /dev/null + +} + + +security() { + + set -e + + pushd "${PROJECT_HOME}" > /dev/null + bandit -r "${PROJECT_NAME}" -c .bandit.rc + safety check + popd > /dev/null + +} + +setup_bash() { + + [[ ! -f /etc/container_release ]] && return + + for filename in /app/development/bash/.bash*; do + echo "Symlinking ${filename} ..." + ln -sf "${filename}" "/home/user/$(basename "${filename}")" + done + +} + +setup_python() { + + unvirtualize + + pushd "${PROJECT_HOME}" > /dev/null + if [[ ! -f /etc/container_release ]]; then + set +e + pipenv --rm + set -e + pipenv --python 3.7 + fi + source_enviroment + reinstall_requirements + unvirtualize + popd > /dev/null + +} + +source_enviroment() { + + if [[ ! -f /etc/container_release ]]; then + + unvirtualize + + # shellcheck disable=SC1090 + source "$(pipenv --venv)/bin/activate" + + fi + + pushd "${PROJECT_HOME}" > /dev/null + set +e + cd .git/hooks + ln -sf ../../scripts/hooks/pre-commit pre-commit + set -e + popd > /dev/null + +} + +unittests() { + + set -e + + pushd "${PROJECT_HOME}" > /dev/null + if [[ $1 == "coverage" ]]; then + shift + set +e + pytest --cov=. --cov-fail-under=100 "$@" + exit_code="$?" + coverage html + set -e + exit "${exit_code}" + else + pytest "$@" + fi + popd > /dev/null + +} + +unvirtualize() { + + if [[ ! -f /etc/container_release ]]; then + + toggle=1 + + if [[ -n "${-//[^e]/}" ]]; then set +e; else toggle=0; fi + if python -c 'import sys; sys.exit(0 if hasattr(sys, "real_prefix") else 1)'; then + deactivate_present=$(LC_ALL=C type deactivate 2>/dev/null) + if [[ -n ${deactivate_present} ]]; then + deactivate + else + exit + fi + fi + if [[ "${toggle}" == "1" ]]; then set -e; fi + + fi + +} diff --git a/scripts/common/upload.sh b/scripts/common/upload.sh new file mode 100755 index 0000000..c4ab34d --- /dev/null +++ b/scripts/common/upload.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1117 + +# Check dependencies. +set -e +[[ -n "${TRACE}" ]] && set -x + +BRed='\033[31m' # Red +BGreen='\033[32m' # Green +NC="\033[0m" # Color Reset + +_OWNER="" +_REPO="" +_TAG="" +_FILENAME="" +_GITHUB_API_TOKEN="" +_ID="" + +main() { + + # Define variables. + GH_API="https://api.github.com" + GH_REPO="$GH_API/repos/${_OWNER}/${_REPO}" + AUTH="Authorization: token ${_GITHUB_API_TOKEN}" + + # Validate token. + curl -o /dev/null -sH "$AUTH" "${GH_REPO}" || { echo "Error: Invalid repo, token or network issue!"; exit 1; } + + # Delete Existing Release + GH_ASSET="https://api.github.com/repos/${_OWNER}/${_REPO}/releases" + EXISTING_RELEASES="$(curl -s -H "Authorization: token ${_GITHUB_API_TOKEN}" "${GH_ASSET}")" + + if jq -e .[].id <<< "${EXISTING_RELEASES}" > /dev/null; then + for release in $(jq .[].id <<< "${EXISTING_RELEASES}"); do + curl -X DELETE -s -H "Authorization: token ${_GITHUB_API_TOKEN}" "${GH_ASSET}/${release}" > /dev/null + echo -e "${BGreen}Deleted Release:${NC} ${BRed}${release}${NC}" + done + fi + + + # Retag the master branch on latest commit local and remotes + set +e + if git push origin :refs/tags/${_TAG} 2>/dev/null; then + git tag -d ${_TAG} 2>/dev/null + git tag ${_TAG} 2>/dev/null + git push origin --tags 2>/dev/null + fi + + + set -e + + # Create New Release, and Fetch it's ID + _ID=$(curl -s -X POST -H "Authorization: token ${_GITHUB_API_TOKEN}" --data "{ \"tag_name\": \"${_TAG}\" }" "https://api.github.com/repos/${_OWNER}/${_REPO}/releases" | jq -r .id) + echo -e "${BGreen}Created Release:${NC} ${BRed}${_ID}${NC}" + + # Look For Existing Assets and Delete As Necessary + GH_ASSET="https://api.github.com/repos/${_OWNER}/${_REPO}/releases" + EXISTING_ASSET="$(curl -s -H "Authorization: token ${_GITHUB_API_TOKEN}" "${GH_ASSET}")" + + if jq -e .[0].assets[0] <<< "${EXISTING_ASSET}" > /dev/null; then + EXISTING_ASSET="$(jq .[0].assets[0].url -r <<< "${EXISTING_ASSET}")" + curl -s -X DELETE -H "Authorization: token ${_GITHUB_API_TOKEN}" "${EXISTING_ASSET}" + echo -e "${BGreen}Deleted Asset:${NC} ${BRed}${EXISTING_ASSET}${NC}" + fi + + # Upload New Assets + GH_ASSET="https://uploads.github.com/repos/${_OWNER}/${_REPO}/releases/${_ID}/assets?name=$(basename ${_FILENAME})" + ASSET=$(curl -s --data-binary @"${_FILENAME}" -H "Authorization: token ${_GITHUB_API_TOKEN}" -H "Content-Type: application/octet-stream" "${GH_ASSET}" | jq .url -r) + + # Pretty Print Result + echo -e "${BGreen}Uploaded:${NC} ${BRed}${ASSET}${NC}" +} + +parse_args() { + + for LINE in "$@"; do + eval "${LINE}" + done + +} + +parse_args "$@" +main diff --git a/scripts/common/wheel.sh b/scripts/common/wheel.sh new file mode 100644 index 0000000..820d5f8 --- /dev/null +++ b/scripts/common/wheel.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC1117 +build_wheel() { + + set -e + + BRed='\033[31m' # Red + BGreen='\033[32m' # Green + NC="\033[0m" # Color Reset + + OWNER="${OWNER}" + REPO="${REPO}" + TAG="${TAG}" + + source_enviroment + pushd "${PROJECT_HOME}" > /dev/null + + if [[ -f .gittoken ]]; then + GITHUB_TOKEN=$(cat .gittoken) + export GITHUB_TOKEN + fi + + rm -rf dist ./*.egg-info build + python setup.py bdist_wheel + mv dist/*.whl . + rm -rf dist ./*.egg-info build + echo -e "\\n${BGreen}Built:${NC} ${BRed}$(ls ./*.whl)${NC}" + + if [[ -n ${GITHUB_TOKEN} ]]; then + ./scripts/common/upload.sh _GITHUB_API_TOKEN="${GITHUB_TOKEN}" _OWNER="${OWNER}" _REPO="${REPO}" _TAG="${TAG}" _FILENAME="$(ls ./*.whl)" + rm ./*.whl + else + echo -e "Set the environment variable ${BRed}GITHUB_TOKEN${NC} to automate the upload to github.\\n" + fi + + popd > /dev/null + +} \ No newline at end of file diff --git a/scripts/dev b/scripts/dev new file mode 100644 index 0000000..15bed75 --- /dev/null +++ b/scripts/dev @@ -0,0 +1,29 @@ +#!/bin/bash + +_script() +{ + + shopt -s expand_aliases + _script_commands=$(dev shortlist) + + local cur + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + + while IFS='' read -r line; do COMPREPLY+=("$line"); done < <(compgen -W "${_script_commands}" -- "${cur}") + return 0 + +} + +complete -o nospace -F _script dev + +dev_identifier() { + Cyan='\033[36m' # Cyan + BRed='\033[31m' # Red + BGreen='\033[32m' # Green + NC="\033[0m" # Color Reset + echo -en "(${BGreen}flask_restful_swagger${NC})" +} + +alias dev='$(git rev-parse --show-toplevel)/scripts/commander.sh' +PROMPT_COMMAND="dev_identifier; $PROMPT_COMMAND" diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100755 index 0000000..945679f --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,44 @@ +#!/bin/bash + +set -eo pipefail + +bypass() { + + # Bypass Unless GitHooks Are Enabled + [[ "${GIT_HOOKS}" == "1" ]] && exit 0 + + local_branch="$(git rev-parse --abbrev-ref HEAD)" + protected_branches="${GIT_HOOKS_PROTECTED_BRANCHES}" + + if [[ ! ${local_branch} =~ ${protected_branches} ]]; then + exit 0 + fi + +} + +main() { + + bypass + + bash scripts/commander.sh lint-validate + bash scripts/commander.sh sectest + bash scripts/commander.sh test + shellcheck -x scripts/*.sh + shellcheck -x scripts/common/*.sh + + if [[ -n "$(git diff)" ]]; then + + git status + + exec < /dev/tty + echo -e "\nWARNING: You have uncommitted changes!" + read -r -p "Type 'yes' to confirm you wish to proceed with this commit: " confirm + [[ ${confirm} != "yes" ]] && echo 'ABORTED' && exit 127 + + exit 0 + + fi + +} + +main diff --git a/Makefile b/scripts/releases/Makefile similarity index 100% rename from Makefile rename to scripts/releases/Makefile diff --git a/setup.py b/setup.py index f95984d..b93a88d 100644 --- a/setup.py +++ b/setup.py @@ -3,26 +3,30 @@ except ImportError: from distutils.core import setup -with open('README') as file: +with open("README") as file: long_description = file.read() -setup(name='flask-restful-swagger', - version='0.20.1', - url='https://github.com/rantav/flask-restful-swagger', - zip_safe=False, - packages=['flask_restful_swagger'], - package_data={ - 'flask_restful_swagger': [ - 'static/*.*', - 'static/css/*.*', - 'static/images/*.*', - 'static/lib/*.*', - 'static/lib/shred/*.*', +setup( + name="flask-restful-swagger", + version="0.20.2", + url="https://github.com/rantav/flask-restful-swagger", + zip_safe=False, + packages=["flask_restful_swagger"], + package_data={ + "flask_restful_swagger": [ + "static/*.*", + "static/css/*.*", + "static/images/*.*", + "static/lib/*.*", + "static/lib/shred/*.*", ] - }, - description='Extract swagger specs from your flast-restful project', - author='Ran Tavory', - license='MIT', - long_description=long_description, - install_requires=['Flask-RESTful>=0.3.6'] - ) + }, + description="Extract swagger specs from your flask-restful project", + author="Ran Tavory", + license="MIT", + long_description=long_description, + install_requires=[ + "Jinja2>=2.10.1,<3.0.0", + "Flask-RESTful>=0.3.6", + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures_add_model.py b/tests/fixtures_add_model.py new file mode 100644 index 0000000..ff25321 --- /dev/null +++ b/tests/fixtures_add_model.py @@ -0,0 +1,236 @@ +try: + from unittest.mock import patch + from unittest.mock import Mock +except ImportError: + from mock import patch + from mock import Mock +from contextlib import contextmanager + +from flask_restful import fields + +from flask_restful_swagger import swagger + + +@contextmanager +def patch_registry(): + with patch("flask_restful_swagger.swagger.registry") as mock_registry: + _temp_dict = {"models": {}} + mock_registry.__getitem__.side_effect = _temp_dict.__getitem__ + mock_registry.__setitem__.side_effect = _temp_dict.__setitem__ + yield _temp_dict + + +@contextmanager +def patch_parse_doc(): + with patch("flask_restful_swagger.swagger._parse_doc") as mock_parse_doc: + mock_parse_doc.return_value = (None, None) + yield mock_parse_doc + + +@contextmanager +def patch_deduce_swagger_type(): + with patch( + "flask_restful_swagger.swagger.deduce_swagger_type" + ) as mock_deduce_swagger_type: + mock_deduce_swagger_type.return_value = "dummy_swagger_type" + yield mock_deduce_swagger_type + + +@contextmanager +def patch_isinstance(patchbool): + with patch("flask_restful_swagger.swagger.isinstance") as mock_isinstance: + mock_isinstance.return_value = patchbool + yield mock_isinstance + + +@contextmanager +def patch_hasattr(): + with patch("flask_restful_swagger.swagger.hasattr") as mock_hasattr: + mock_hasattr.return_value = True + yield mock_hasattr + + +@contextmanager +def patch_dir(patchreturn): + with patch("flask_restful_swagger.swagger.dir") as mock_dir: + mock_dir.return_value = patchreturn + yield mock_dir + + +@contextmanager +def patch_getargspec(): + with patch( + "flask_restful_swagger.swagger.inspect.getargspec" + ) as mock_getargspec: + mock_argspec = Mock() + mock_argspec.args = ["self", "arg1", "arg2", "arg3"] + mock_argspec.defaults = ("123",) + mock_getargspec.return_value = mock_argspec + yield mock_getargspec + + +############################################################################### +# Copy setup objects from examples/basic.py +############################################################################### + +MockBasicObjectNoInit = Mock() +MockBasicObjectNoInit.__name__ = MockBasicObjectNoInit + + +class MockBasicObject: + def __init__(self, arg1): + pass + + +class MockBasicWithSwaggerMetadata1: + def __init__(self, arg1): + pass + + swagger_metadata = {"an_enum": {"enum": ["one", "two", "three"]}} + + +class MockBasicWithSwaggerMetadata2: + def __init__(self, arg1, an_enum): + pass + + swagger_metadata = {"an_enum": {"enum": ["one", "two", "three"]}} + + +class MockTodoItem: + """This is an example of a model class that has parameters in its constructor + and the fields in the swagger spec are derived from the parameters + to __init__. + In this case we would have args, arg2 as required parameters and arg3 as + optional parameter. + """ + + def __init__(self, arg1, arg2, arg3="123"): + pass + + +class MockModelWithResourceFieldsNoRequired: + resource_fields = {"a_string": fields.String()} + + +class MockModelWithResourceFieldsWithRequired: + resource_fields = {"a_string": fields.String()} + + required = ["a_string"] + + +@swagger.nested( + a_nested_attribute=MockModelWithResourceFieldsNoRequired.__name__, + a_list_of_nested_types=MockModelWithResourceFieldsNoRequired.__name__, +) +class MockModelWithResourceFieldsWithRequiredWithSwaggerMetadata: + resource_fields = { + "a_string": fields.String(), + "an_enum": fields.String, + } + required = ["a_string"] + swagger_metadata = {"an_enum": {"enum": ["one", "two", "three"]}} + + +@swagger.nested( + a_nested_attribute=MockModelWithResourceFieldsNoRequired.__name__, + a_list_of_nested_types=MockModelWithResourceFieldsNoRequired.__name__, +) +class MockTodoItemWithResourceFields: + """This is an example of how Output Fields work + (http://flask-restful.readthedocs.org/en/latest/fields.html). + Output Fields lets you add resource_fields to your model in which you + specify the output of the model when it gets sent as an HTTP response. + flask-restful-swagger takes advantage of this to specify the fields in + the model + """ + + resource_fields = { + "a_string": fields.String(attribute="a_string_field_name"), + "a_formatted_string": fields.FormattedString, + "an_enum": fields.String, + "an_int": fields.Integer, + "a_bool": fields.Boolean, + "a_url": fields.Url, + "a_float": fields.Float, + "an_float_with_arbitrary_precision": fields.Arbitrary, + "a_fixed_point_decimal": fields.Fixed, + "a_datetime": fields.DateTime, + "a_list_of_strings": fields.List(fields.String), + "a_nested_attribute": fields.Nested( + MockModelWithResourceFieldsNoRequired.resource_fields + ), + "a_list_of_nested_types": fields.List( + fields.Nested( + MockModelWithResourceFieldsNoRequired.resource_fields + ) + ), + } + + # Specify which of the resource fields are required + required = ["a_string"] + + +############################################################################### +# Tests Fixtures +############################################################################### + +fixtures_integration_test_add_model = [ + (MockBasicObject, [], [], []), + (MockTodoItem, ["arg1", "arg2", "arg3"], ["arg1", "arg2"], ["arg3"]), + (MockModelWithResourceFieldsNoRequired, ["a_string"], [], []), + ( + MockTodoItemWithResourceFields, + [ + "a_string", + "a_formatted_string", + "an_enum", + "an_int", + "a_bool", + "a_url", + "a_float", + "an_float_with_arbitrary_precision", + "a_fixed_point_decimal", + "a_datetime", + "a_list_of_strings", + "a_nested_attribute", + "a_list_of_nested_types", + ], + ["a_string"], + [], + ), + (MockBasicWithSwaggerMetadata1, [], [], []), + (MockBasicWithSwaggerMetadata2, [], [], []), +] + +fixtures_add_model_get_docs = [ + MockBasicObject, + MockTodoItem, + MockModelWithResourceFieldsNoRequired, + MockTodoItemWithResourceFields, +] + +fixtures_add_model_with_resource_fields_without_swagger_metadata = [ + MockModelWithResourceFieldsWithRequired, +] + +fixtures_add_model_with_resource_fields_with_nested = [ + MockTodoItemWithResourceFields, +] + +fixtures_add_model_with_resource_fields_nested_swagger_metadata = [ + MockModelWithResourceFieldsWithRequiredWithSwaggerMetadata, +] + + +fixtures_add_model_no_properties = [ + MockBasicObjectNoInit, +] + +fixtures_add_model_init = [ + MockBasicObject, + MockTodoItem, +] + +fixtures_add_model_init_parsing_args = [ + [MockTodoItem, ["arg1", "arg2"], [("arg3", "123")]] +] diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lib/helpers.py b/tests/lib/helpers.py new file mode 100644 index 0000000..108ba5b --- /dev/null +++ b/tests/lib/helpers.py @@ -0,0 +1,40 @@ +import sys +from types import CodeType, FunctionType +from unittest import TestCase + + +def freeVar(val): + def nested(): + return val + + return nested.__closure__[0] + + +def find_nested_func(parent, child_name, **kwargs): + """Returns a function nested inside another function. + :param parent: The parent function to search inside. + :type parent: func + :param child_name: A string containing the name of the child function. + :type child_name: string + :returns: The nested function, or None + """ + if sys.version_info[0] < 3: + consts = parent.func_code.co_consts + else: + consts = parent.__code__.co_consts + for item in consts: + if isinstance(item, CodeType): + if item.co_name == child_name: + return FunctionType( + item, + globals(), + None, + None, + tuple(freeVar(name) for name in item.co_freevars), + ) + return None + + +class TestCaseSupport(TestCase): + def runTest(self): + pass diff --git a/tests/test_add_model.py b/tests/test_add_model.py new file mode 100644 index 0000000..0aefae5 --- /dev/null +++ b/tests/test_add_model.py @@ -0,0 +1,259 @@ +import pytest + +from flask_restful_swagger import swagger +from tests.fixtures_add_model import ( + fixtures_add_model_get_docs, + fixtures_add_model_init, + fixtures_add_model_init_parsing_args, + fixtures_add_model_no_properties, + fixtures_add_model_with_resource_fields_nested_swagger_metadata, + fixtures_add_model_with_resource_fields_with_nested, + fixtures_add_model_with_resource_fields_without_swagger_metadata, + fixtures_integration_test_add_model, + patch_deduce_swagger_type, + patch_dir, + patch_getargspec, + patch_hasattr, + patch_isinstance, + patch_parse_doc, + patch_registry, +) + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +@pytest.mark.parametrize( + "test_input,properties,required,defaults", + fixtures_integration_test_add_model, +) +def test_integration_test_add_model( + test_input, properties, required, defaults +): + """Integration test for `add_model(...)` method. + + Ensures models are added to `registry["models"]` with expected structure. + Example each model should have 'description', 'id','notes', 'properties', + etc. + Example `registry["models"]`: + # print(registry["models"]) + { 'models': { ..... + 'MockTodoItem': { 'description': 'This is an example of a ' + 'model class that has ' + 'parameters in its ' + 'constructor', + 'id': 'MockTodoItem', + 'notes': 'and the fields in the swagger spec ' + 'are derived from the ' + 'parameters
to __init__.
In ' + 'this case we would have args, arg2 ' + 'as required parameters and arg3 ' + 'as
optional parameter.', + 'properties': { 'arg1': {'type': 'string'}, + 'arg2': {'type': 'string'}, + 'arg3': { 'default': '123', + 'type': 'string'}}, + 'required': ['arg1', 'arg2']}, + .......... + """ + with patch_registry() as registry: + swagger.add_model(test_input) + + assert test_input.__name__ in registry["models"] + assert "description" in registry["models"][test_input.__name__] + assert "notes" in registry["models"][test_input.__name__] + + if "resource_fields" not in dir(test_input) and "__init__" not in dir( + test_input + ): + # in py2, classes without __init__ or resource_fields defined + # will cause issues. + # note, no issue in PY3. + pytest.fail( + "do not call without resource_fields or __init__ defined." + ) + + if "resource_fields" in dir(test_input): + if hasattr(test_input, "required"): + assert "required" in registry["models"][test_input.__name__] + elif "__init__" in dir(test_input): + assert "required" in registry["models"][test_input.__name__] + + assert "properties" in registry["models"][test_input.__name__] + + +@pytest.mark.parametrize("input_model", fixtures_add_model_get_docs) +def test_add_model_get_docs(input_model): + """Ensure `_parse_doc(...)` is called without issues""" + with patch_registry(), patch_parse_doc() as mock_parse_doc: + swagger.add_model(input_model) + mock_parse_doc.assert_called_once_with(input_model) + + +@patch("flask_restful_swagger.swagger._Nested", spec=swagger._Nested) +@pytest.mark.parametrize( + "mock_model_class", + fixtures_add_model_with_resource_fields_without_swagger_metadata, +) +def test_add_model_with_resource_fields_without_swagger_metadata( + mock_nested, mock_model_class, +): + """Test adding model with resource fields, no init, without swagger metadata. + """ + pdst = patch_deduce_swagger_type + pr = patch_registry + ppd = patch_parse_doc + pha = patch_hasattr + + with pr(), ppd(), patch_isinstance(False) as mock_isinstance: + with pha() as mock_hasattr, patch_dir(["resource_fields"]) as mock_dir: + with pdst() as mock_deduce_swagger_type: + + swagger.add_model(mock_model_class) + mock_dir.assert_called_with(mock_model_class) + assert mock_dir.call_count == 2 + mock_hasattr.assert_called_once_with( + mock_model_class, "required") + mock_isinstance.assert_called_with( + mock_model_class, mock_nested) + assert mock_deduce_swagger_type.call_count == len( + mock_model_class.resource_fields.items() + ) + + +@pytest.mark.parametrize( + "model_class", fixtures_add_model_with_resource_fields_with_nested +) +def test_add_model_with_resource_fields_with_nested(model_class,): + """Test for model with resource fields, nested subclass + + * resource_fields: YES + * nested subclass: YES + * __init__: NO + * swagger_metadata:NO + + """ + pdst = patch_deduce_swagger_type + pr = patch_registry + ppd = patch_parse_doc + pha = patch_hasattr + + with pr(), ppd(), patch_isinstance(True) as mock_isinstance: + with pha() as mock_hasattr, patch_dir(["resource_fields"]) as mock_dir: + with pdst() as mock_deduce_swagger_type: + + swagger.add_model(model_class) + mock_dir.assert_called_with(model_class) + assert mock_dir.call_count == 2 + mock_hasattr.assert_called_once_with(model_class, "required") + mock_isinstance.assert_called_with( + model_class, swagger._Nested) + assert mock_deduce_swagger_type.call_count == len( + model_class.resource_fields.items() + ) + + +@pytest.mark.parametrize( + "model_class", + fixtures_add_model_with_resource_fields_nested_swagger_metadata, +) +def test_add_model_with_resource_fields_nested_swagger_metadata(model_class,): + """Test for model with resource fields, nested subclass, swagger metadata + + * resource_fields: YES + * nested subclass: YES + * __init__: NO + * swagger_metadata:YES + """ + pdst = patch_deduce_swagger_type + pr = patch_registry + ppd = patch_parse_doc + pha = patch_hasattr + + with pr(), ppd(), patch_isinstance(True) as mock_isinstance: + with pha() as mock_hasattr: + with patch_dir(["resource_fields"]) as mock_dir: + with pdst() as mock_deduce_swagger_type: + swagger.add_model(model_class) + + mock_dir.assert_called_with(model_class) + assert mock_dir.call_count == 2 + mock_hasattr.assert_called_once_with( + model_class, "required") + mock_isinstance.assert_called_with( + model_class, swagger._Nested) + assert mock_deduce_swagger_type.call_count == len( + model_class.resource_fields.items() + ) + + +@pytest.mark.parametrize("model_class", fixtures_add_model_init) +def test_add_model_init(model_class): + """Test for model with only init + + * resource_fields: NO + * nested subclass: NO + * __init__: YES + * swagger_metadata: NO + """ + pdst = patch_deduce_swagger_type + pr = patch_registry + ppd = patch_parse_doc + pgas = patch_getargspec + pha = patch_hasattr + + with pdst() as mock_deduce_swagger_type: + with patch_dir(["__init__"]), pr(), ppd(), pgas() as mock_getargspec: + with pha() as mock_hasattr: + swagger.add_model(model_class) + mock_getargspec.assert_called_once_with(model_class.__init__) + mock_hasattr.assert_not_called() + mock_deduce_swagger_type.assert_not_called() + + +@pytest.mark.parametrize("model_class", fixtures_add_model_no_properties) +def test_add_model_no_init(model_class): + """Test for model with only init + + * resource_fields: NO + * nested subclass: NO + * __init__: NO + * swagger_metadata: NO + """ + pdst = patch_deduce_swagger_type + pr = patch_registry + ppd = patch_parse_doc + pgas = patch_getargspec + pha = patch_hasattr + + with pdst() as mock_deduce_swagger_type: + with pr(), ppd(), pgas() as mock_getargspec: + with pha() as mock_hasattr: + swagger.add_model(model_class) + mock_getargspec.assert_not_called() + mock_hasattr.assert_not_called() + mock_deduce_swagger_type.assert_not_called() + + +@pytest.mark.parametrize( + "model_class,required,defaults", fixtures_add_model_init_parsing_args +) +def test_add_model_init_parsing_args(model_class, required, defaults): + """Test to verify args parsed correctly + """ + with patch_registry() as registry, patch_parse_doc(), patch_dir( + ["__init__"] + ): + swagger.add_model(model_class) + + assert model_class.__name__ in registry["models"] + assert registry["models"][model_class.__name__]["required"] == required + for key, default_value in defaults: + _name = model_class.__name__ + assert key in registry["models"][_name]["properties"] + assert ( + default_value + == registry["models"][_name]["properties"][key]["default"] + ) diff --git a/tests/test_deduce_swagger_type.py b/tests/test_deduce_swagger_type.py new file mode 100644 index 0000000..e066d01 --- /dev/null +++ b/tests/test_deduce_swagger_type.py @@ -0,0 +1,89 @@ +import sys + +import pytest +from flask_restful import fields + +from flask_restful_swagger import swagger + + +@pytest.mark.parametrize( + "case_name, test_input, expected", + [ + ("Null", None, {"type": "null"}), + ("Simple False", False, {"type": "boolean"}), + ("Simple True", True, {"type": "boolean"}), + ("Integer", 1, {"type": "integer"}), + ("Very large integer", sys.maxsize, {"type": "integer"}), + ("Float less than 1", 0.8092, {"type": "number"}), + ("Float greater than 1", 98763.09, {"type": "number"}), + ("String", "helloWorld!", {"type": "string"}), + ], +) +def test_deduce_swagger_type_instances(case_name, test_input, expected): + assert swagger.deduce_swagger_type(test_input) == expected + + +@pytest.mark.parametrize( + "field_type, expected", + [ + ("Boolean", {"type": "boolean"}), + ("Integer", {"type": "integer"}), + ("Arbitrary", {"type": "number"}), + ("Fixed", {"type": "number"}), + ("DateTime", {"type": "date-time"}), + ], +) +def test_deduce_swagger_type_flask_field(field_type, expected): + new_field = getattr(fields, field_type)() + assert swagger.deduce_swagger_type(new_field) == expected + + +@pytest.mark.parametrize( + "case_name, object_type, expected", + [ + ("Class derived from string", str, {"type": "string"}), + ("Class derived from integer", int, {"type": "integer"}), + ("Class derived from float", float, {"type": "number"}), + ], +) +def test_deduce_swagger_type_create_new_class( + case_name, object_type, expected): + class NewSubClass(object_type): + pass + + new_instance = NewSubClass() + assert swagger.deduce_swagger_type(new_instance) == expected + + +@pytest.mark.parametrize( + "case_name, object_type, expected", + [ + ("Class derived from string", str, {"type": "string"}), + ("Class derived from integer", int, {"type": "integer"}), + ("Class derived from float", float, {"type": "number"}), + ("Class derived from fields.List", fields.List, {"type": "array"}), + ], +) +def test_deduce_swagger_type_with_class(case_name, object_type, expected): + class NewSubClass(object_type): + pass + + assert swagger.deduce_swagger_type(NewSubClass) == expected + + +def test_deduce_swagger_type_fields_formatted_string(): + new_instance = fields.FormattedString("Hello {name}") + + assert swagger.deduce_swagger_type(new_instance) == {"type": "string"} + + +def test_deduce_swagger_type_fields_list_instance(): + new_instance = fields.List(fields.String) + + assert "items" in swagger.deduce_swagger_type(new_instance) + + +def test_deduce_swagger_type_fields_nested_instance(): + new_instance = fields.Nested({}) + + assert swagger.deduce_swagger_type(new_instance) == {"type": None} diff --git a/tests/test_deduce_swagger_type_flat.py b/tests/test_deduce_swagger_type_flat.py new file mode 100644 index 0000000..b10b921 --- /dev/null +++ b/tests/test_deduce_swagger_type_flat.py @@ -0,0 +1,65 @@ +import pytest +from flask_restful import fields + +from flask_restful_swagger import swagger + + +@pytest.mark.parametrize( + "case_name, test_input, expected", + [ + ("Null", None, None), + ("Simple False", False, "boolean"), + ("Simple True", True, "boolean"), + ("Integer", 1, "integer"), + ("Very large integer", 9223372036854775807, "integer"), + ("Float less than 1", 0.8092, "number"), + ("Float greater than 1", 98763.09, "number"), + ("String", "helloWorld!", "string"), + ], +) +def test_deduce_swagger_type_flat_instances(case_name, test_input, expected): + assert swagger.deduce_swagger_type_flat(test_input) == expected + + +@pytest.mark.parametrize( + "field_type, expected", + [ + ("Boolean", "boolean"), + ("Integer", "integer"), + ("Arbitrary", "number"), + ("Fixed", "number"), + ("DateTime", "date-time"), + ], +) +def test_deduce_swagger_type_flat_flask_field(field_type, expected): + new_field = getattr(fields, field_type)() + assert swagger.deduce_swagger_type_flat(new_field) == expected + + +@pytest.mark.parametrize( + "case_name, object_type, expected", + [ + ("Class derived from string", str, "string"), + ("Class derived from integer", int, "integer"), + ("Class derived from float", float, "number"), + ], +) +def test_deduce_swagger_type_flat_create_new_class( + case_name, object_type, expected +): + class NewSubClass(object_type): + pass + + new_instance = NewSubClass() + assert swagger.deduce_swagger_type_flat(new_instance) == expected + + +def test_deduce_swagger_type_flat_with_nested_object(): + assert swagger.deduce_swagger_type_flat("anything", "cookies") == "cookies" + + +def test_deduce_swagger_type_flat_with_class(): + class NewSubClass(str): + pass + + assert swagger.deduce_swagger_type_flat(NewSubClass) == "string" diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..3845a31 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,74 @@ +import types + +from flask import Blueprint, Flask +from flask_restful import Api, Resource + +from flask_restful_swagger.swagger import docs + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + +docs_kwargs = { + "apiVersion": "an api version", + "basePath": "a basepath", + "resourcePath": "a resource path", + "produces": ["application/json", "text/html"], + "api_spec_url": "an api spec url", + "description": "an Api Description Description", +} + + +def test_docs_simple_instantiate(): + + app = Flask(__name__) + app.config["basePath"] = "/abc/123" + my_blueprint1 = Blueprint("my_blueprint1", __name__) + + api1 = docs(Api(my_blueprint1), **docs_kwargs) + + assert api1.add_resource.__name__ == "add_resource" + assert isinstance(api1.add_resource, types.FunctionType) + + +@patch("flask_restful_swagger.swagger.register_once") +@patch("flask_restful_swagger.swagger.make_class") +@patch("flask_restful_swagger.swagger.swagger_endpoint") +@patch("flask_restful_swagger.swagger.extract_swagger_path") +def test_docs_simple_instantiate_add_resources( + path, endpoint, make_class, register +): + + my_blueprint1 = Blueprint("my_blueprint1", __name__) + + api1 = docs(Api(my_blueprint1), **docs_kwargs) + + class MockResource(Resource): + def get(self): + return "OK", 200, {"Access-Control-Allow-Origin": "*"} + + make_class.return_value = MockResource + endpoint.return_value = MockResource + path.return_value = "/some/swagger/path" + + api1.add_resource(MockResource, "/some/url") + + # Validate All Mock Calls + + assert register.call_args_list[0][0][0] == api1 + assert register.call_args_list[0][0][2:] == ( + "an api version", + "1.2", + "a basepath", + "a resource path", + ["application/json", "text/html"], + "an api spec url", + "an Api Description Description", + ) + assert len(register.call_args_list[0][0]) == 9 + + path.assert_called_once_with("/some/url") + assert endpoint.call_args_list[0][0][0] == api1 + assert endpoint.call_args_list[0][0][2] == "/some/url" + make_class.assert_called_once_with(MockResource) diff --git a/tests/test_extract_path_arguments.py b/tests/test_extract_path_arguments.py new file mode 100644 index 0000000..ef17e1d --- /dev/null +++ b/tests/test_extract_path_arguments.py @@ -0,0 +1,96 @@ +import pytest + +from flask_restful_swagger.swagger import extract_path_arguments +from .lib.helpers import find_nested_func + + +@pytest.mark.parametrize( + "path,expected", + [ + ("/path/with/no/parameters", []), + ( + "/path/", + [ + { + "name": "parameter", + "dataType": "string", + "paramType": "path", + }, + ], + ), + ( + ( + "///" + "" + ), + [ + { + "name": "lang_code", + "dataType": "string", + "paramType": "path", + }, + { + "name": "identifier", + "dataType": "string", + "paramType": "path", + }, + { + "name": "probability", + "dataType": "float", + "paramType": "path", + }, + ], + ), + ( + ( + "///" + "" + ), + [ + { + "name": "lang_code", + "dataType": "string", + "paramType": "path", + }, + { + "name": "identifier", + "dataType": "float", + "paramType": "path", + }, + { + "name": "ready_to_proceed", + "dataType": "bool", + "paramType": "path", + }, + ], + ), + ], +) +def test_extract_path(path, expected): + assert extract_path_arguments(path) == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + ( + "not_declared", + { + "name": "not_declared", + "dataType": "string", + "paramType": "path", + }, + ), + ( + "int:identifier", + {"name": "identifier", "dataType": "int", "paramType": "path"}, + ), + ( + "float:amount", + {"name": "amount", "dataType": "float", "paramType": "path"}, + ), + ], +) +def test_nested_split_args(arg, expected): + split_arg = find_nested_func(extract_path_arguments, "split_arg") + assert split_arg(arg) == expected diff --git a/tests/test_extract_swagger_path.py b/tests/test_extract_swagger_path.py new file mode 100644 index 0000000..72bbd6c --- /dev/null +++ b/tests/test_extract_swagger_path.py @@ -0,0 +1,39 @@ +import pytest + +from flask_restful_swagger import swagger + + +@pytest.mark.parametrize( + "case_name, test_input, expected", + [ + ("empty_string", "", ""), + ("simple", "/endpoint", "/endpoint"), + ("single_parameter_no_type", "/path/", "/path/{parameter}"), + ( + "single_parameter_string", + "/", + "/{lang_code}", + ), + ( + "multiple_parameters", + "///", + "/{lang_code}/{id}/{probability}", + ), + ( + "multiple_parameters_varied_length_string", + "///", + "/{lang_code}/{id}/{probability}", + ), + ( + "long_path_single_parameter", + "path/subpath/other_path/", + "path/subpath/other_path/{lang_code}", + ), + ], +) +def test_extract_swagger_path(case_name, test_input, expected): + assert swagger.extract_swagger_path(test_input) == expected + + +def test_extract_swagger_path_returns_string(): + assert isinstance(swagger.extract_swagger_path("/endpoint/123"), str) diff --git a/tests/test_get_current_registry.py b/tests/test_get_current_registry.py new file mode 100644 index 0000000..8925212 --- /dev/null +++ b/tests/test_get_current_registry.py @@ -0,0 +1,121 @@ +import sys + +from flask import Blueprint, Flask +from flask_restful import Api, Resource + +from flask_restful_swagger import swagger +from flask_restful_swagger.swagger import _get_current_registry +from .lib.helpers import TestCaseSupport + +tc = TestCaseSupport() +tc.maxDiff = None + + +def test_get_current_registry_simple(): + app = Flask(__name__) + + with app.test_request_context(path="/some_path.html"): + registry = _get_current_registry() + + assert registry == {"basePath": "http://localhost", "models": {}} + + +def test_get_current_registry_request_features(): + + app = Flask(__name__) + app.config["basePath"] = "/abc/123" + my_blueprint1 = Blueprint("my_blueprint1", __name__) + api1 = swagger.docs( + Api(my_blueprint1), + apiVersion="0.1", + basePath="http://localhost:5000", + resourcePath="/", + produces=["application/json", "text/html"], + api_spec_url="/api/spec", + description="Blueprint1 Description", + ) + + class MockResource(Resource): + @swagger.operation() + def get(self): + return "OK", 200, {"Access-Control-Allow-Origin": "*"} + + app.register_blueprint(my_blueprint1, url_prefix="") + api1.add_resource(MockResource, "/some/urls") + + with app.test_request_context(path="some_path.html"): + registry = _get_current_registry(api=api1) + + description = ( + "Represents an abstract RESTful resource. " "Concrete resources should" + ) + notes = ( + "extend from this class and expose methods for each " + "supported HTTP
method. If a resource is invoked " + "with an unsupported HTTP method,
the API will " + "return a response with status 405 Method Not Allowed." + "
Otherwise the appropriate method is called and " + "passed all arguments
from the url rule used when " + "adding the resource to an Api instance. " + "See
:meth:`~flask_restful.Api.add_resource` " + "for details." + ) + + if sys.version_info[0] < 3: + notes = None + description = None + + tc.assertDictEqual( + registry, + { + "apiVersion": "0.1", + "swaggerVersion": "1.2", + "basePath": "http://localhost:5000", + "spec_endpoint_path": "/api/spec", + "resourcePath": "/", + "produces": ["application/json", "text/html"], + "x-api-prefix": "", + "apis": [ + { + "path": "/some/urls", + "description": description, + "notes": notes, + "operations": [ + { + "method": "get", + "nickname": "nickname", + "notes": None, + "parameters": [], + "summary": None, + } + ], + } + ], + "description": "Blueprint1 Description", + "models": {}, + }, + ) + + +def test_get_current_registry_request_features_and_docs(): + app = Flask(__name__) + app.config["basePath"] = "/abc/123" + my_blueprint1 = Blueprint("my_blueprint1", __name__) + app.register_blueprint(my_blueprint1, url_prefix="") + _ = swagger.docs( + Api(my_blueprint1), + apiVersion="0.1", + basePath="http://localhost:5000", + resourcePath="/", + produces=["application/json", "text/html"], + api_spec_url="/api/spec", + description="Blueprint1 Description", + ) + + with app.test_request_context(path="some_path.html"): + registry = _get_current_registry() + + tc.assertDictEqual( + registry, + {"basePath": "http://localhost", "models": {}} + ) diff --git a/tests/test_make_class.py b/tests/test_make_class.py new file mode 100644 index 0000000..cb71e26 --- /dev/null +++ b/tests/test_make_class.py @@ -0,0 +1,21 @@ +from flask_restful_swagger import swagger + + +def test_make_class_with_input_class(): + class TestClass: + pass + + assert swagger.make_class(TestClass) == TestClass + + +def test_make_class_with_input_instance(): + class TestClass: + pass + + test_class = TestClass() + + assert swagger.make_class(test_class) == TestClass + + +def test_make_class_with_none(): + assert isinstance(None, swagger.make_class(None)) diff --git a/tests/test_merge_parameter_list.py b/tests/test_merge_parameter_list.py new file mode 100644 index 0000000..f1663c7 --- /dev/null +++ b/tests/test_merge_parameter_list.py @@ -0,0 +1,126 @@ +import pytest + +from flask_restful_swagger import swagger + + +def test_merge_parameter_list_empty_lists(): + assert swagger.merge_parameter_list([], []) == [] + + +def test_merge_parameter_list_no_changes(): + base = [ + { + "method": "ABC", + "parameters": "None", + "nickname": "ABC", + "name": "ABC", + } + ] + overrides = [] + assert swagger.merge_parameter_list(base, overrides) == base + + +@pytest.mark.parametrize( + "overrides, expected", + [ + [ + ({"parameters": "None", "name": "ABC"}), + ({"parameters": "None", "name": "ABC"}), + ], + [ + ( + { + "method": "GET", + "parameters": "Parameters", + "nickname": "ABC", + "name": "ABC", + } + ), + ( + { + "method": "GET", + "parameters": "Parameters", + "nickname": "ABC", + "name": "ABC", + } + ), + ], + [ + ({"extra_parameter": "something", "name": "ABC"}), + ({"extra_parameter": "something", "name": "ABC"}), + ], + [ + ({"extra_parameter": "something", "name": "ABC"}), + ({"extra_parameter": "something", "name": "ABC"}), + ], + ], +) +def test_merge_parameter_list_with_changes(overrides, expected): + base = [ + { + "method": "ABC", + "parameters": "Some", + "nickname": "ABC", + "name": "ABC", + } + ] + assert swagger.merge_parameter_list(base, [overrides]) == [expected] + + +@pytest.mark.parametrize( + "overrides, expected", + [ + [ + [ + { + "method": "ABCD", + "parameters": "Some", + "nickname": "ABCD", + "name": "ABCD", + }, + { + "method": "ABC", + "parameters": "Some", + "nickname": "ABC", + "name": "ABC", + }, + ], + [ + { + "method": "ABC", + "parameters": "Some", + "nickname": "ABC", + "name": "ABC", + }, + { + "method": "ABCDE", + "parameters": "Some", + "nickname": "ABCDE", + "name": "ABCDE", + }, + { + "method": "ABCD", + "parameters": "Some", + "nickname": "ABCD", + "name": "ABCD", + }, + ], + ], + ], +) +def test_merge_parameter_list_appended(overrides, expected): + base = [ + { + "method": "ABC", + "parameters": "Some", + "nickname": "ABC", + "name": "ABC", + }, + { + "method": "ABCDE", + "parameters": "Some", + "nickname": "ABCDE", + "name": "ABCDE", + }, + ] + assert swagger.merge_parameter_list(base, overrides) == expected diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..ed2cc31 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,30 @@ +import datetime + +import pytest + +from flask_restful_swagger import swagger + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +class TestEmptyClass: + pass + + +@pytest.mark.parametrize( + "test_input", + [ + TestEmptyClass, # Test a class + "im a str", # Test a str + 123, # Test int + None, # Test None + datetime.datetime.now(), # Test datetime + ], +) +def test_model_with_input(test_input): + with patch("flask_restful_swagger.swagger.add_model") as mock_add_model: + assert swagger.model(test_input) == test_input + mock_add_model.assert_called_once_with(test_input) diff --git a/tests/test_nested_class.py b/tests/test_nested_class.py new file mode 100644 index 0000000..ac0d9a0 --- /dev/null +++ b/tests/test_nested_class.py @@ -0,0 +1,32 @@ +from flask_restful_swagger.swagger import _Nested + + +class MockClass(object): + pass + + +def mock_function(*args, **kwargs): + return args, kwargs + + +def test_nested_class(): + + kwargs = {"arg1": 1, "arg2": "Helllllllo!"} + + instance = _Nested(MockClass, **kwargs) + + assert isinstance(instance, _Nested) + assert instance._klass == MockClass + assert instance._nested == kwargs + assert instance.nested() == kwargs + + +def test_nested_function(): + + kwargs = {"arg1": 1, "arg2": "Helllllllo!"} + args = ("hello", "there") + + instance = _Nested(mock_function, **kwargs) + value1, value2 = instance(*args, **kwargs) + assert value1 == args + assert value2 == kwargs diff --git a/tests/test_nested_func.py b/tests/test_nested_func.py new file mode 100644 index 0000000..5104676 --- /dev/null +++ b/tests/test_nested_func.py @@ -0,0 +1,32 @@ +import types + +import pytest + +from flask_restful_swagger.swagger import _Nested, nested + + +class MockClass(object): + pass + + +def test_nested_without_a_class(): + ret = nested(None, kwargs={"arg1": 1, "arg2": "Helllllllo!"}) + assert isinstance(ret, types.FunctionType) + assert ret.__name__ == "wrapper" + + +def test_wrapped_object_is_correct(): + ret = nested(klass=None, kwargs={"arg1": 1, "arg2": "Helllllllo!"}) + resolved = ret(MockClass) + assert isinstance(resolved, _Nested) + assert isinstance(resolved, _Nested) + + +@pytest.mark.parametrize( + "testcase_klass, testcase_kwargs", + [(MockClass, {}), (MockClass, {"arg1": 1, "arg2": "Helllllllo!"})], +) +def test_nested_with_a_class(testcase_klass, testcase_kwargs): + ret = nested(klass=testcase_klass, **testcase_kwargs) + assert isinstance(ret, _Nested) + assert ret._klass == MockClass diff --git a/tests/test_operation.py b/tests/test_operation.py new file mode 100644 index 0000000..c28b8a5 --- /dev/null +++ b/tests/test_operation.py @@ -0,0 +1,33 @@ +import pytest + +from flask_restful_swagger import swagger + + +def empty_func(): + pass + + +def func_with_many_args(arg1, arg2, arg3, kwarg1=None, kwarg2=None): + allargs = (arg1, arg2, arg3, kwarg1, kwarg2) + print("func_with_many_args: %s, %s, %s, %s, %s" % allargs) + + +class EmptyClass: + pass + + +@pytest.mark.parametrize( + "plain_input,swagger_kwargs", + [ + (empty_func, {"arg1": None, "arg2": None}), + (func_with_many_args, {"arg1": None, "arg2": None}), + (EmptyClass, {"arg1": None}), + (EmptyClass(), {"arg1": None}), + ], +) +def test_operation(plain_input, swagger_kwargs): + _add_swagger_attr_wrapper = swagger.operation(**swagger_kwargs) + swaggered_input = _add_swagger_attr_wrapper(plain_input) + + assert hasattr(swaggered_input, "__swagger_attr") + assert swaggered_input.__swagger_attr == swagger_kwargs diff --git a/tests/test_parse_docs.py b/tests/test_parse_docs.py new file mode 100644 index 0000000..c92b979 --- /dev/null +++ b/tests/test_parse_docs.py @@ -0,0 +1,52 @@ +from flask_restful_swagger import swagger + + +class MockBasicObject: + pass + + +def test_parse_doc_no_object_is_none(): + assert swagger._parse_doc(None) == (None, None) + + +def test_parse_doc_no_docs_is_none(): + assert swagger._parse_doc(MockBasicObject()) == (None, None) + + +def test_parse_doc_one_line_doc(): + test_one_line_doc = MockBasicObject() + test_one_line_doc.__doc__ = "Some Text Goes Here" + assert swagger._parse_doc(test_one_line_doc) == ( + "Some Text Goes Here", + None, + ) + + +def test_parse_doc_multi_line_doc(): + test_multi_line_doc = MockBasicObject() + test_multi_line_doc.__doc__ = ( + "Some Text Goes Here \n this is the extra text\n" + "and this is the third line." + ) + extracted = swagger._parse_doc(test_multi_line_doc) + assert extracted[0] == "Some Text Goes Here " + assert ( + extracted[1] + == " this is the extra text
and this is the third line." + ) + + +def test_parse_doc_weird_characters(): + test_weird_characters = MockBasicObject() + test_weird_characters.__doc__ = ( + "Hi, 297agiu(*#&_$ ! \n Oh, the terrible 2908*&%)(#%#" + ) + extracted = swagger._parse_doc(test_weird_characters) + assert extracted[0] == "Hi, 297agiu(*#&_$ ! " + assert extracted[1] == "Oh, the terrible 2908*&%)(#%#" + + +def test_parse_doc_ends_with_newline(): + test_ends_newline = MockBasicObject() + test_ends_newline.__doc__ = "Overview \n Some details \n" + assert swagger._parse_doc(test_ends_newline)[1] == "Some details " diff --git a/tests/test_register_once.py b/tests/test_register_once.py new file mode 100644 index 0000000..45a32bf --- /dev/null +++ b/tests/test_register_once.py @@ -0,0 +1,190 @@ +from flask import Blueprint +from flask_restful import Api + +import flask_restful_swagger +from .lib.helpers import find_nested_func + + +class MockRegistration: + def __init__(self): + self.args = [] + self.kwargs = [] + + def reset(self): + self.args = [] + self.kwargs = [] + + def add(self, *args, **kwargs): + self.args.append(args) + self.kwargs.append(kwargs) + + +class MockState(object): + def __init__(self, blueprint=None, url_prefix=None): + self.blueprint = blueprint + self.url_prefix = url_prefix + + +mock_registration = MockRegistration() + + +register_once_kwargs = { + "add_resource_func": mock_registration.add, + "apiVersion": "0.1", + "swaggerVersion": "1.2", + "basePath": "https://localhost:5000", + "resourcePath": "/api/spec", + "produces": ["application/json"], + "endpoint_path": "/endpoint", + "description": "Mock API Registry Data", +} + +registry_content_for_test = { + "apiVersion": "0.1", + "swaggerVersion": "1.2", + "basePath": "https://localhost:5000", + "spec_endpoint_path": "/endpoint", + "resourcePath": "/api/spec", + "produces": ["application/json"], + "x-api-prefix": "", + "apis": [], + "description": "Mock API Registry Data", +} + + +def test_register_once_blueprint(): + + mock_registration.reset() + + if "app" in flask_restful_swagger.registry: + del flask_restful_swagger.registry["app"] + + my_blueprint1 = Blueprint("test", __name__) + api = Api(my_blueprint1) + + kwargs = dict(register_once_kwargs) + kwargs["api"] = api + + flask_restful_swagger.swagger.register_once(**kwargs) + assert flask_restful_swagger.registry["test"] == registry_content_for_test + flask_restful_swagger.swagger.register_once(**kwargs) + assert flask_restful_swagger.registry["test"] == registry_content_for_test + + assert mock_registration.args == [ + ( + flask_restful_swagger.swagger.SwaggerRegistry, + "/endpoint", + "/endpoint.json", + "/endpoint.html", + ), + ( + flask_restful_swagger.swagger.ResourceLister, + "/endpoint/_/resource_list.json", + ), + ( + flask_restful_swagger.swagger.StaticFiles, + "/endpoint/_/static///", + "/endpoint/_/static//", + "/endpoint/_/static/", + ), + ( + flask_restful_swagger.swagger.SwaggerRegistry, + "/endpoint", + "/endpoint.json", + "/endpoint.html", + ), + ( + flask_restful_swagger.swagger.ResourceLister, + "/endpoint/_/resource_list.json", + ), + ( + flask_restful_swagger.swagger.StaticFiles, + "/endpoint/_/static///", + "/endpoint/_/static//", + "/endpoint/_/static/", + ), + ] + assert mock_registration.kwargs == [ + {}, + {}, + {}, + {"endpoint": "app/registry"}, + {"endpoint": "app/resourcelister"}, + {"endpoint": "app/staticfiles"}, + ] + + +def test_register_once_without_blueprint(): + + mock_registration.reset() + + if "app" in flask_restful_swagger.registry: + del flask_restful_swagger.registry["app"] + + api = Api() + kwargs = dict(register_once_kwargs) + kwargs["api"] = api + + flask_restful_swagger.swagger.register_once(**kwargs) + assert flask_restful_swagger.registry["test"] == registry_content_for_test + flask_restful_swagger.swagger.register_once(**kwargs) + assert flask_restful_swagger.registry["test"] == registry_content_for_test + + assert mock_registration.args == [ + ( + flask_restful_swagger.swagger.SwaggerRegistry, + "/endpoint", + "/endpoint.json", + "/endpoint.html", + ), + ( + flask_restful_swagger.swagger.ResourceLister, + "/endpoint/_/resource_list.json", + ), + ( + flask_restful_swagger.swagger.StaticFiles, + "/endpoint/_/static///", + "/endpoint/_/static//", + "/endpoint/_/static/", + ), + ] + + assert mock_registration.kwargs == [ + {"endpoint": "app/registry"}, + {"endpoint": "app/resourcelister"}, + {"endpoint": "app/staticfiles"}, + ] + + +def test_register_test_deferred_setup(): + + if "app" in flask_restful_swagger.registry: + del flask_restful_swagger.registry["app"] + if "registered_blueprint" in flask_restful_swagger.registry: + del flask_restful_swagger.registry["registered_blueprint"] + + blueprint = Blueprint("registered_blueprint", __name__) + api = Api(blueprint) + + kwargs = dict(register_once_kwargs) + kwargs["api"] = api + + flask_restful_swagger.swagger.register_once(**kwargs) + + assert ( + flask_restful_swagger.registry["registered_blueprint"]["x-api-prefix"] + == "" + ) + + func = find_nested_func( + flask_restful_swagger.swagger.register_once, "registering_blueprint" + ) + state = MockState(blueprint=blueprint, url_prefix="/none") + + func.__globals__["registry"] = flask_restful_swagger.registry + func(state) + + assert ( + flask_restful_swagger.registry["registered_blueprint"]["x-api-prefix"] + == "/none" + ) diff --git a/tests/test_render_endpoint.py b/tests/test_render_endpoint.py new file mode 100644 index 0000000..59f6ca8 --- /dev/null +++ b/tests/test_render_endpoint.py @@ -0,0 +1,24 @@ +import unittest + +from flask_restful_swagger import swagger + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +class Endpoint: + pass + + +class TestRenderEndpoint(unittest.TestCase): + def test_render_endpoint(self): + endpoint = Endpoint() + with patch( + "flask_restful_swagger.swagger.render_page" + ) as mock_render_page: + swagger.render_endpoint(endpoint) + mock_render_page.assert_called_with( + "endpoint.html", endpoint.__dict__ + ) diff --git a/tests/test_render_hompage.py b/tests/test_render_hompage.py new file mode 100644 index 0000000..926fdaa --- /dev/null +++ b/tests/test_render_hompage.py @@ -0,0 +1,16 @@ +from flask_restful_swagger import swagger + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +@patch("flask_restful_swagger.swagger.render_page") +@patch("flask.wrappers.Response") +def test_render_hompage_func(response_obj, mock_render_page): + resource_list_url = "resource_list_url" + swagger.render_homepage(resource_list_url) + mock_render_page.assert_called_once_with( + "index.html", {"resource_list_url": resource_list_url} + ) diff --git a/tests/test_render_page.py b/tests/test_render_page.py new file mode 100644 index 0000000..e41571f --- /dev/null +++ b/tests/test_render_page.py @@ -0,0 +1,58 @@ +from flask import Response + +from flask_restful_swagger import swagger + +try: + from unittest.mock import patch, mock_open +except ImportError: + from mock import patch, mock_open + + +@patch("flask_restful_swagger.swagger._get_current_registry") +@patch("flask_restful_swagger.swagger.open", new_callable=mock_open) +def test_render_page(mocked_open, test_reg): + test_reg.return_value = { + "apiVersion": "mock_version", + "swaggerVersion": "mock_swagger_version", + "basePath": "mock_path", + "spec_endpoint_path": "mock_spec_endpoint_path", + "description": "mock_description", + } + + result = swagger.render_page("docs.html", None) + assert isinstance(result, Response) + + +@patch("flask_restful_swagger.swagger._get_current_registry") +@patch("flask_restful_swagger.swagger.open", new_callable=mock_open) +def test_render_page_with_slash(mocked_open, test_reg): + test_reg.return_value = { + "apiVersion": "mock_version", + "swaggerVersion": "mock_swagger_version", + "basePath": "mock_path/", + "spec_endpoint_path": "mock_spec_endpoint_path", + "description": "mock_description", + } + + result_with_trailing_slash = swagger.render_page( + "docs.html", {"some info": "info"} + ) + assert isinstance(result_with_trailing_slash, Response) + + +@patch("flask_restful_swagger.swagger._get_current_registry") +@patch("flask_restful_swagger.swagger.open", new_callable=mock_open) +def test_render_page_in_js(mocked_open, test_reg): + test_reg.return_value = { + "apiVersion": "mock_version", + "swaggerVersion": "mock_swagger_version", + "basePath": "mock_path/", + "spec_endpoint_path": "mock_spec_endpoint_path", + "description": "mock_description", + } + + result_with_js = swagger.render_page("docs.js", {"some info": "info"}) + assert ( + result_with_js.headers["Content-Type"] + == "text/javascript; charset=utf-8" + ) diff --git a/tests/test_resource_lister.py b/tests/test_resource_lister.py new file mode 100644 index 0000000..1e0696e --- /dev/null +++ b/tests/test_resource_lister.py @@ -0,0 +1,32 @@ +from flask_restful_swagger.swagger import ResourceLister + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +@patch("flask_restful_swagger.swagger.render_page") +@patch("flask_restful_swagger.swagger._get_current_registry") +def test_get_valid_content_renders(registry, render_page): + + expected_result = { + "apiVersion": "mock_version", + "swaggerVersion": "mock_swagger_version", + "apis": [ + { + "path": "mock_pathmock_spec_endpoint_path", + "description": "mock_description", + } + ], + } + + registry.return_value = { + "apiVersion": "mock_version", + "swaggerVersion": "mock_swagger_version", + "basePath": "mock_path", + "spec_endpoint_path": "mock_spec_endpoint_path", + "description": "mock_description", + } + resource_lister = ResourceLister() + assert resource_lister.get() == expected_result diff --git a/tests/test_sanitize_doc.py b/tests/test_sanitize_doc.py new file mode 100644 index 0000000..5315727 --- /dev/null +++ b/tests/test_sanitize_doc.py @@ -0,0 +1,19 @@ +import pytest + +from flask_restful_swagger import swagger + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ("Hey\n", "Hey
"), + ("\n/n\n/n\n", "
/n
/n
"), + ("No Change", "No Change"), + ], +) +def test_string_sanitize_doc(test_input, expected): + assert swagger._sanitize_doc(test_input) == expected + + +def test_none_sanitize_doc(): + assert swagger._sanitize_doc(None) is None diff --git a/tests/test_staticfiles.py b/tests/test_staticfiles.py new file mode 100644 index 0000000..b188cca --- /dev/null +++ b/tests/test_staticfiles.py @@ -0,0 +1,144 @@ +import os + +import pytest + +import flask_restful_swagger +from flask_restful_swagger.swagger import StaticFiles + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +test_fixtures_renders = [ + ["index.html", None, None], + ["o2c.html", None, None], + ["swagger-ui.js", None, None], + ["swagger-ui.min.js", None, None], + ["lib/swagger-oauth.js", None, None], +] + + +@patch("flask_restful_swagger.swagger.render_page") +@patch("flask_restful_swagger.swagger._get_current_registry") +@pytest.mark.parametrize("dir1,dir2,dir3", test_fixtures_renders) +def test_get_valid_content_renders(registry, render_page, dir1, dir2, dir3): + + static_files = StaticFiles() + registry.return_value = {"spec_endpoint_path": "dummy"} + + static_files.get(dir1, dir2, dir3) + assert render_page.call_args[0] == (dir1, {"resource_list_url": "dummy"}) + + +test_fixtures_none = [[None, None, None]] + + +@patch("flask_restful_swagger.swagger.render_page") +@patch("flask_restful_swagger.swagger._get_current_registry") +@pytest.mark.parametrize("dir1,dir2,dir3", test_fixtures_none) +def test_get_valid_content_renders_none( + registry, render_page, dir1, dir2, dir3 +): + + static_files = StaticFiles() + registry.return_value = {"spec_endpoint_path": "dummy"} + + static_files.get(dir1, dir2, dir3) + assert render_page.call_args[0] == ( + "index.html", + {"resource_list_url": "dummy"}, + ) + + +test_fixtures_mimes = [ + ["index2.html", "text/plain"], + ["image.gif", "image/gif"], + ["image.png", "image/png"], + ["javascript.js", "text/javascript"], + ["style.css", "text/css"], +] + + +@patch("flask_restful_swagger.swagger.Response", autospec=True) +@patch("flask_restful_swagger.swagger.open") +@patch("flask_restful_swagger.swagger.os.path.exists") +@patch("flask_restful_swagger.swagger._get_current_registry") +@pytest.mark.parametrize("dir1,mime", test_fixtures_mimes) +def test_get_valid_content_mime( + registry, mock_exists, mock_open, response, dir1, mime +): + + mock_open.return_value = "file_handle" + mock_exists.return_value = True + + static_files = StaticFiles() + static_files.get(dir1, None, None) + assert mock_exists.called + assert mock_open.called + + args, kwargs = response.call_args_list[0] + assert args == ("file_handle",) + assert kwargs == {"mimetype": mime} + + +test_fixtures_mimes_does_not_exist = ["index2.html"] + + +@patch("flask_restful_swagger.swagger.os.path.exists") +@patch("flask_restful_swagger.swagger._get_current_registry") +@patch("flask_restful_swagger.swagger.abort") +@pytest.mark.parametrize("dir1", test_fixtures_mimes_does_not_exist) +def test_get_valid_content_mime_file_does_not_exist( + abort, registry, mock_exists, dir1 +): + + mock_exists.return_value = False + static_files = StaticFiles() + static_files.get(dir1, None, None) + assert mock_exists.called + assert abort.called + + +test_fixtures_paths = [ + ["paths", "index2.html", None, "paths/index2.html"], + ["paths", "more_paths", "index2.html", "paths/more_paths/index2.html"], +] + + +@patch("flask_restful_swagger.swagger.Response", autospec=True) +@patch("flask_restful_swagger.swagger.os.path.exists") +@patch("flask_restful_swagger.swagger.open") +@patch("flask_restful_swagger.swagger.render_page") +@patch("flask_restful_swagger.swagger._get_current_registry") +@pytest.mark.parametrize("dir1,dir2,dir3,expected", test_fixtures_paths) +def test_get_valid_content_paths( + registry, + render_page, + mock_open, + mock_exists, + response, + dir1, + dir2, + dir3, + expected, +): + + mock_open.return_value = "file_handle" + mock_exists.return_value = True + + static_files = StaticFiles() + registry.return_value = {"spec_endpoint_path": "dummy"} + + static_files.get(dir1, dir2, dir3) + module_path = os.path.dirname(flask_restful_swagger.__file__) + static_files = "static" + full_path = os.path.join(module_path, static_files, expected) + + assert mock_exists.called + assert mock_open.call_args_list[0][0][0] == full_path + + args, kwargs = response.call_args_list[0] + assert args == ("file_handle",) + assert kwargs == {"mimetype": "text/plain"} diff --git a/tests/test_swagger_endpoint_class.py b/tests/test_swagger_endpoint_class.py new file mode 100644 index 0000000..000d939 --- /dev/null +++ b/tests/test_swagger_endpoint_class.py @@ -0,0 +1,187 @@ +import pytest +from flask_restful import Resource + +from flask_restful_swagger.swagger import SwaggerEndpoint, operation +from .lib.helpers import TestCaseSupport + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +class MockDataType(object): + pass + + +tc = TestCaseSupport() +tc.maxDiff = None + + +@patch("flask_restful_swagger.swagger.extract_swagger_path") +@patch("flask_restful_swagger.swagger.extract_path_arguments") +@patch("flask_restful_swagger.swagger._parse_doc") +@patch("flask_restful_swagger.swagger.SwaggerEndpoint.extract_operations") +def test_swagger_endpoint(operations, docs, args, path): + + path.return_value = "Sometime Soon" + args.return_value = "I Will Return" + docs.return_value = ("A Description Will Follow", "As to Where to Meet") + operations.return_value = ["knee surgery", "back surgery"] + + endpoint = SwaggerEndpoint("Fake Resource", "/some/path") + + assert path.called + assert args.called + assert docs.called + assert operations.called + + assert endpoint.path == "Sometime Soon" + assert endpoint.description == "A Description Will Follow" + assert endpoint.notes == "As to Where to Meet" + assert endpoint.operations == ["knee surgery", "back surgery"] + + operations.assert_called_once_with("Fake Resource", "I Will Return") + + +def test_swagger_endpoint_extract_operations_empty(): + class MockResource(Resource): + def get(self): + return "OK", 200, {"Access-Control-Allow-Origin": "*"} + + assert SwaggerEndpoint.extract_operations(MockResource, []) == [] + + +@pytest.mark.parametrize( + "mock_properties, update_with", + [ + ( + { + "name": "one", + "method": "get", + "other": MockDataType, + "parameters": [ + { + "name": "identifier", + "description": "identifier", + "required": True, + "allowMultiple": False, + "dataType": "string", + "paramType": "path", + }, + { + "name": "identifier2", + "description": "identifier2", + "required": True, + "allowMultiple": False, + "dataType": "float", + "paramType": "path", + }, + ], + }, + { + "method": "get
get", + "nickname": "nickname", + "summary": None, + "notes": None, + "other": "MockDataType", + }, + ), + ], +) +@patch("flask_restful_swagger.swagger._get_current_registry") +def test_get_swagger_endpoint_not_subclassed_basic_example( + registry, mock_properties, update_with +): + + registry.return_value = { + "apiVersion": "mock_version", + "swaggerVersion": "mock_swagger_version", + "basePath": "mock_path", + "spec_endpoint_path": "mock_spec_endpoint_path", + "description": "mock_description", + } + + class MockResource(Resource): + @operation(**mock_properties) + def get(self): + return "OK", 200, {"Access-Control-Allow-Origin": "*"} + + return_value = SwaggerEndpoint.extract_operations( + MockResource, + [ + {"name": "identifier", "dataType": "string", "paramType": "path"}, + {"name": "identifier2", "dataType": "float", "paramType": "path"}, + ], + ) + mock_properties.update(update_with) + tc.assertDictEqual(return_value[0], mock_properties) + + +@pytest.mark.parametrize( + "mock_properties, update_with", + [ + ( + { + "name": "one", + "method": "get", + "other": MockDataType, + "parameters": [ + { + "name": "identifier", + "description": "identifier", + "required": True, + "allowMultiple": False, + "dataType": "string", + "paramType": "path", + }, + { + "name": "identifier2", + "description": "identifier2", + "required": True, + "allowMultiple": False, + "dataType": "float", + "paramType": "path", + }, + ], + }, + { + "method": "get
get", + "nickname": "nickname", + "summary": None, + "notes": None, + "other": "MockDataType", + }, + ), + ], +) +@patch("flask_restful_swagger.swagger._get_current_registry") +def test_get_swagger_endpoint_subclassed_basic_example( + registry, mock_properties, update_with +): + + registry.return_value = { + "apiVersion": "mock_version", + "swaggerVersion": "mock_swagger_version", + "basePath": "mock_path", + "spec_endpoint_path": "mock_spec_endpoint_path", + "description": "mock_description", + } + + class MockResource(Resource): + @operation(**mock_properties) + def get(self): + return "OK", 200, {"Access-Control-Allow-Origin": "*"} + + class MockSubClass(MockResource): + pass + + return_value = SwaggerEndpoint.extract_operations( + MockSubClass, + [ + {"name": "identifier", "dataType": "string", "paramType": "path"}, + {"name": "identifier2", "dataType": "float", "paramType": "path"}, + ], + ) + mock_properties.update(update_with) + tc.assertDictEqual(return_value[0], mock_properties) diff --git a/tests/test_swagger_endpoint_func.py b/tests/test_swagger_endpoint_func.py new file mode 100644 index 0000000..99b216c --- /dev/null +++ b/tests/test_swagger_endpoint_func.py @@ -0,0 +1,63 @@ +from bs4 import BeautifulSoup +from flask import Flask +from flask_restful import Resource + +from flask_restful_swagger.swagger import swagger_endpoint + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +@patch("flask_restful_swagger.swagger._get_current_registry") +def test_get_swagger_endpoint(registry): + registry.return_value = { + "apiVersion": "mock_version", + "swaggerVersion": "mock_swagger_version", + "basePath": "mock_path", + "spec_endpoint_path": "mock_spec_endpoint_path", + "description": "mock_description", + } + + class MockResource(Resource): + def get(self): + return "OK", 200, {"Access-Control-Allow-Origin": "*"} + + app = Flask(__name__) + + resource = swagger_endpoint("some_api", MockResource, "/some_path") + bases = [base.__name__ for base in resource.__mro__] + + assert sorted(bases) == [ + "MethodView", + "Resource", + "SwaggerResource", + "View", + "object", + ] + + with app.test_request_context(path="/some_path.help.json"): + resource_instance = resource() + response = resource_instance.get() + assert sorted(list(response.keys())) == [ + "description", + "notes", + "operations", + "path", + ] + assert response["path"] == "/some_path" + assert response["operations"] == [] + + with app.test_request_context(path="/some_path.help.html"): + resource_instance = resource() + response = resource_instance.get() + assert response.status_code == 200 + assert isinstance(response.data, bytes) + assert BeautifulSoup( + response.data.decode("utf-8"), "html.parser" + ).find() + + with app.test_request_context(path="/some_path"): + resource_instance = resource() + assert resource_instance.get() is None diff --git a/tests/test_swagger_registry.py b/tests/test_swagger_registry.py new file mode 100644 index 0000000..7db6de9 --- /dev/null +++ b/tests/test_swagger_registry.py @@ -0,0 +1,49 @@ +from flask import Flask + +from flask_restful_swagger.swagger import SwaggerRegistry + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +@patch("flask_restful_swagger.swagger._get_current_registry") +@patch("flask_restful_swagger.swagger.render_homepage") +def test_get_swagger_registry(homepage, registry): + + mock_registry = { + "apiVersion": "mock_version", + "swaggerVersion": "mock_swagger_version", + "basePath": "mock_path", + "spec_endpoint_path": "mock_spec_endpoint_path", + "description": "mock_description", + } + + registry.return_value = mock_registry + + app = Flask(__name__) + + resource = SwaggerRegistry() + bases = [base.__name__ for base in SwaggerRegistry.__mro__] + + assert sorted(bases) == [ + "MethodView", + "Resource", + "SwaggerRegistry", + "View", + "object", + ] + + with app.test_request_context(path="/some_path.html"): + _ = resource.get() + assert homepage.called + homepage.assert_called_once_with( + "mock_pathmock_spec_endpoint_path/_/resource_list.json" + ) + + with app.test_request_context(path="/some_path"): + homepage.reset_mock() + response = resource.get() + assert not homepage.called + assert response == mock_registry