Skip to content

Commit

Permalink
Merge pull request #18 from iterait/dev
Browse files Browse the repository at this point in the history
v 0.1.0
  • Loading branch information
petrbel authored Oct 10, 2018
2 parents 045c851 + ef785bd commit 66a4eb1
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 16 deletions.
43 changes: 39 additions & 4 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ references:
name: Install dependencies and apistrap on Archlinux
command: |
set -x
pip install coveralls coverage
pip install -e .
test: &test
Expand All @@ -20,13 +21,21 @@ references:
name: PyPI deploy
command: |
pip3 install wheel setuptools --upgrade
bash <(curl -fsSL https://raw.githubusercontent.com/Cognexa/ci-utils/master/pypi_deploy.sh)
bash <(curl -fsSL https://raw.githubusercontent.com/iterait/ci-utils/master/pypi_deploy.sh)
coverage: &coverage
run:
name: Report test coverage
command: |
coverage run setup.py test
coverage report
COVERALLS_REPO_TOKEN=QEAuo5Pufwd8TGqQiqt7a1HWWp3i9mL0Y coveralls
jobs:

test_archlinux:
docker:
- image: cognexa/archlinux:latest
- image: iterait/archlinux
working_directory: ~/apistrap
steps:
- setup_remote_docker:
Expand All @@ -36,22 +45,48 @@ jobs:
- *install
- *test

coverage:
docker:
- image: iterait/archlinux
working_directory: ~/apistrap
steps:
- checkout
- *install
- *coverage

deploy:
docker:
- image: cognexa/archlinux
- image: iterait/archlinux
working_directory: ~/apistrap
steps:
- checkout
- *install
- *deploy

workflows:
version: 2
test:
test-doc-deploy:
jobs:
- test_archlinux
- coverage:
requires:
- test_archlinux
- deploy:
filters:
branches:
only: master
requires:
- test_archlinux
- coverage

nightly-build:
triggers:
- schedule:
cron: "0 0 * * *"
filters:
branches:
only:
- master
- dev
jobs:
- test_archlinux
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Apistrap - HTTP API utilities

[![CircleCI](https://circleci.com/gh/iterait/apistrap.png?circle-token=6e0633d5636dd5b858dd4db501695e10b16f373f)](https://circleci.com/gh/iterait/apistrap/tree/master)
[![CircleCI](https://circleci.com/gh/iterait/apistrap.png?style=shield&circle-token=6e0633d5636dd5b858dd4db501695e10b16f373f)](https://circleci.com/gh/iterait/apistrap/tree/master)
[![Development Status](https://img.shields.io/badge/status-CX%20Regular-brightgreen.svg?style=flat)]()
[![Master Developer](https://img.shields.io/badge/master-Jan%20Buchar-lightgrey.svg?style=flat)]()

Expand Down
35 changes: 27 additions & 8 deletions apistrap/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
from functools import wraps
from typing import Type, Sequence, Callable, Optional

from flask import request, jsonify, Response
from flask import request, jsonify, Response, send_file
from schematics import Model
from schematics.exceptions import DataError

import apistrap.flask
from apistrap.errors import ApiClientError, InvalidFieldsError, InvalidResponseError, UnexpectedResponseError
from apistrap.schematics_converters import schematics_model_to_schema_object
from apistrap.types import FileResponse


def _ensure_specs_dict(func: Callable):
Expand Down Expand Up @@ -97,21 +98,30 @@ class RespondsWithDecorator:
"""

def __init__(self, swagger: 'apistrap.flask.Swagger', response_class: Type[Model], *,
code: int=200, description: Optional[str]=None):
code: int=200, description: Optional[str]=None, mimetype: Optional[str]=None):
self._response_class = response_class
self._code = code
self._swagger = swagger
self._description = description or self._response_class.__name__
self._mimetype = mimetype

def __call__(self, wrapped_func: Callable):
_ensure_specs_dict(wrapped_func)

wrapped_func.specs_dict["responses"][str(self._code)] = {
"schema": {
"$ref": self._swagger.add_definition(self._response_class.__name__, self._get_schema_object())
},
"description": self._description
}
if self._response_class == FileResponse:
wrapped_func.specs_dict["responses"][str(self._code)] = {
"schema": {'type': 'file'},
"description": self._description
}
if self._mimetype is not None:
wrapped_func.specs_dict["produces"] = self._mimetype
else:
wrapped_func.specs_dict["responses"][str(self._code)] = {
"schema": {
"$ref": self._swagger.add_definition(self._response_class.__name__, self._get_schema_object())
},
"description": self._description
}

innermost_func = _get_wrapped_function(wrapped_func)
self.outermost_decorators[innermost_func] = self
Expand All @@ -126,6 +136,15 @@ def wrapper(*args, **kwargs):
if self.outermost_decorators[innermost_func] == self:
raise UnexpectedResponseError(type(response))
return response
if isinstance(response, FileResponse):
return send_file(filename_or_fp=response.filename_or_fp,
mimetype=self._mimetype,
as_attachment=response.as_attachment,
attachment_filename=response.attachment_filename,
add_etags=response.add_etags,
cache_timeout=response.cache_timeout,
conditional=response.conditional,
last_modified=response.last_modified)

try:
response.validate()
Expand Down
10 changes: 8 additions & 2 deletions apistrap/flask.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from copy import deepcopy
import logging
from typing import Type, Sequence, Optional

from flasgger import Swagger as Flasgger
Expand Down Expand Up @@ -45,6 +46,7 @@ def http_error_handler(self, exception: HTTPException):
:return: a response object
"""

logging.exception(exception)
return jsonify(ErrorResponse(dict(message=exception.description)).to_primitive()), exception.code

def error_handler(self, exception):
Expand All @@ -55,6 +57,7 @@ def error_handler(self, exception):
"""

if self.app.debug:
logging.exception(exception)
error_response = ErrorResponse(dict(
message=str(exception),
debug_data=format_exception(exception)
Expand All @@ -71,6 +74,8 @@ def internal_error_handler(self, exception):
:return: a response object
"""

logging.exception(exception)

if self.app.debug:
error_response = ErrorResponse(dict(
message=str(exception),
Expand Down Expand Up @@ -165,12 +170,13 @@ def autodoc(self, *, ignored_args: Sequence[str] = ()):
"""
return AutodocDecorator(self, ignored_args=ignored_args)

def responds_with(self, response_class: Type[Model], *, code: int=200, description: Optional[str]=None):
def responds_with(self, response_class: Type[Model], *, code: int=200, description: Optional[str]=None,
mimetype: Optional[str]=None):
"""
A decorator that fills in response schemas in the Swagger specification. It also converts Schematics models
returned by view functions to JSON and validates them.
"""
return RespondsWithDecorator(self, response_class, code=code, description=description)
return RespondsWithDecorator(self, response_class, code=code, description=description, mimetype=mimetype)

def accepts(self, request_class: Type[Model]):
"""
Expand Down
7 changes: 7 additions & 0 deletions apistrap/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ class ErrorResponse(Model):

message: str = StringType(required=True)
debug_data: Mapping[str, Any] = DictType(BaseType, required=False, serialize_when_none=False)


class EmptyResponse(Model):
"""
An empty message wrapper
"""
pass
22 changes: 21 additions & 1 deletion apistrap/types.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
from typing import List, Sequence, Iterable
from datetime import datetime
from io import BytesIO
from typing import List, Sequence, Iterable, Union
from typing.io import BinaryIO

from schematics.exceptions import BaseError, CompoundError, ConversionError
from schematics.types import CompoundType


class FileResponse:
"""
File response used instead of `flask.send_file`.
Note that MIME type should be handled by `responds_with` decorator.
"""
def __init__(self, filename_or_fp: Union[str, BinaryIO, BytesIO], as_attachment: bool=False,
attachment_filename: str=None, add_etags: bool=True, cache_timeout: int=None, conditional: bool=False,
last_modified: Union[datetime, int]=None):
self.filename_or_fp = filename_or_fp
self.as_attachment = as_attachment
self.attachment_filename = attachment_filename
self.add_etags = add_etags
self.cache_timeout = cache_timeout
self.conditional = conditional
self.last_modified = last_modified


class TupleType(CompoundType):
"""A field for storing a fixed length tuple of items. The values must conform to the types
specified by the ``fields`` parameter.
Expand Down
30 changes: 30 additions & 0 deletions tests/test_responds_with_decorator.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import io
import pytest
from schematics import Model
from schematics.types import StringType

from apistrap.errors import UnexpectedResponseError, InvalidResponseError
from apistrap.types import FileResponse
from apistrap.schemas import EmptyResponse


def extract_definition_name(definition_spec: str):
Expand Down Expand Up @@ -57,6 +60,21 @@ def weird_view():
def invalid_view():
return OkResponse(dict())

@app.route("/file")
@swagger.autodoc()
@swagger.responds_with(FileResponse)
def get_file():
message = 'hello'
return FileResponse(filename_or_fp=io.BytesIO(message.encode('UTF-8')),
as_attachment=True,
attachment_filename='hello.txt')

@app.route("/empty")
@swagger.autodoc()
@swagger.responds_with(EmptyResponse)
def get_empty_response():
return EmptyResponse()


def test_responses_in_swagger_json(app_with_responds_with, client):
response = client.get("/swagger.json")
Expand Down Expand Up @@ -127,3 +145,15 @@ def test_weird_response(app_with_responds_with, client, propagate_exceptions):
def test_invalid_response(app_with_responds_with, client, propagate_exceptions):
with pytest.raises(InvalidResponseError):
client.get("/invalid")


def test_file_response(app_with_responds_with, client):
response = client.get("/file")
assert response.status_code == 200
assert response.data == b'hello'


def test_empty_response(app_with_responds_with, client):
response = client.get("/empty")
assert response.status_code == 200
assert response.json == {}

0 comments on commit 66a4eb1

Please sign in to comment.