Skip to content

Commit

Permalink
0.0.16
Browse files Browse the repository at this point in the history
- add file upload
- apidocs generation fix for form
  • Loading branch information
wvolkov authored Apr 11, 2021
2 parents 050458c + 73dda47 commit 43a1b6d
Show file tree
Hide file tree
Showing 12 changed files with 151 additions and 51 deletions.
27 changes: 15 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ from marshmallow import Schema, fields, ValidationError, post_load
from starlette.applications import Starlette
from starlette.datastructures import UploadFile
from starlette.responses import JSONResponse
from apispec.ext.marshmallow import MarshmallowPlugin
from apispec import APISpec

from dataclasses import dataclass
from datetime import datetime

from star_resty import Method, Operation, endpoint, json_schema, json_payload, form_payload, query, setup_spec
from star_resty import Method, Operation, endpoint, json_schema, json_payload, upload, query, setup_spec, form_payload
from typing import Optional

class EchoInput(Schema):
Expand All @@ -37,6 +40,7 @@ class JsonPayloadSchema(Schema):
a = fields.Int(required=True)
s = fields.String()

ma_plugin = MarshmallowPlugin()

# Json Payload (by dataclass)
@dataclass
Expand All @@ -54,15 +58,9 @@ class JsonPayloadDataclass(Schema):


# Form Payload
class FormFile(fields.Field):
def _validate(self, value):
if not isinstance(value, UploadFile):
raise ValidationError('Not a file')


class FormPayload(Schema):
id = fields.Int(required=True)
file = FormFile()
file_dt = fields.DateTime()


app = Starlette(debug=True)
Expand Down Expand Up @@ -105,10 +103,15 @@ class PostDataclass(Method):
class PostForm(Method):
meta = Operation(tag='default', description='post form')

async def execute(self, form_data: form_payload(FormPayload)):
file_name = form_data.get('file').filename
async def execute(self, form_data: form_payload(FormPayload),
files_reqired: upload('selfie', 'doc', required=True),
files_optional: upload('file1', 'file2', 'file3')):
files = {}
for file in files_reqired + files_optional:
body = await file.read()
files[file.filename] = f"{body.hex()[:10]}..."
id = form_data.get('id')
return {'message': f"file {file_name} with id {id} received"}
return {'message': f"files received (id: {id})", "files": files}


if __name__ == '__main__':
Expand All @@ -118,4 +121,4 @@ if __name__ == '__main__':
uvicorn.run(app, port=8080)
```

Open [http://localhost:8080/apidocs.json](http://localhost:8080/apidocs.json) to view generated openapi schema.
Open [http://localhost:8080/apidocs.json](http://localhost:8080/apidocs.json) to view generated openapi schema.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def get_packages(package):
'apispec<4',
'python-multipart'
],
version='0.0.15',
version='0.0.16',
url='https://github.com/slv0/start_resty',
license='BSD',
description='The web framework',
Expand Down
16 changes: 6 additions & 10 deletions star_resty/apidocs/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ def resolve_parameters(endpoint: Method):
return parameters

for p in parser:
if p.schema is not None and p.location != 'body':
parameters.append({'in': p.location, 'schema': p.schema})
if not p.is_body:
parameters.extend(p.get_spec())

return parameters

Expand All @@ -30,8 +30,8 @@ def resolve_request_body(endpoint: Method):
def resolve_request_body_content(parser: RequestParser):
content = {}
for p in parser:
if p.schema is not None and p.location == 'body' and p.media_type:
content[p.media_type] = {'schema': p.schema}
if p.is_body:
content.update(p.get_body_spec())

return content

Expand All @@ -43,11 +43,7 @@ def resolve_request_body_params(endpoint: Method):
return params

for p in parser:
if p.schema is not None and p.location == 'body' and p.media_type:
params.append({
'name': 'body',
'in': 'body',
'schema': p.schema
})
if p.is_body:
params.extend(p.get_spec())

return params
2 changes: 1 addition & 1 deletion star_resty/method/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from starlette.requests import Request

from star_resty.payload.parser import Parser
from star_resty.payload.base import Parser

__all__ = ('RequestParser', 'create_parser')

Expand Down
3 changes: 2 additions & 1 deletion star_resty/payload/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .form import form_payload, form_schema
from .header import header, header_schema
from .json import json_payload, json_schema
from .path import path, path_schema
from .query import query, query_schema
from .form import form_payload, form_schema
from .upload import upload
50 changes: 37 additions & 13 deletions star_resty/payload/parser.py → star_resty/payload/base.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
import abc
import inspect
from typing import Dict, Optional, Type, Union
from functools import lru_cache
from typing import Dict, Optional, Type, Union, Iterable, Mapping, Tuple

from marshmallow import EXCLUDE, Schema
from starlette.requests import Request
from functools import lru_cache

__all__ = ('Parser', 'set_parser')
__all__ = ('Parser', 'SchemaParser', 'set_parser')


class Parser(abc.ABC):
__slots__ = ()

@abc.abstractmethod
def parse(self, request: Request):
raise NotImplementedError

@staticmethod
def get_spec() -> Iterable[Mapping]:
return ()

@staticmethod
def get_body_spec() -> Iterable[Tuple[str, Mapping]]:
return ()

@property
def location(self) -> Optional[str]:
return None

@property
def media_type(self) -> Optional[str]:
return None

@property
def is_body(self) -> bool:
return self.location == 'body'



class SchemaParser(Parser, metaclass=abc.ABCMeta):
__slots__ = ('schema', 'unknown')

@classmethod
Expand All @@ -33,17 +62,12 @@ def __init__(self, schema: Schema, unknown=EXCLUDE):
self.schema = schema
self.unknown = unknown

@abc.abstractmethod
def parse(self, request: Request):
pass
def get_spec(self):
yield {'in': self.location, 'schema': self.schema}

@property
def location(self) -> Optional[str]:
return None

@property
def media_type(self) -> Optional[str]:
return None
def get_body_spec(self):
if self.media_type:
yield self.media_type, {'schema': self.schema}


def set_parser(parser: Parser):
Expand Down
10 changes: 5 additions & 5 deletions star_resty/payload/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from starlette.requests import Request

from star_resty.exceptions import DecodeError
from .parser import Parser, set_parser
from .base import SchemaParser, set_parser

__all__ = ('form_schema', 'form_payload', 'FormParser')

Expand All @@ -22,12 +22,12 @@ def form_payload(schema: Union[Schema, Type[Schema]], unknown=EXCLUDE) -> Type[M
return form_schema(schema, Mapping, unknown=unknown)


class FormParser(Parser):
class FormParser(SchemaParser):
__slots__ = ()

@property
def location(self):
return 'body'
return 'formData'

@property
def media_type(self):
Expand All @@ -36,7 +36,7 @@ def media_type(self):
async def parse(self, request: Request):
try:
form_data = await request.form()
form_data = {} if not form_data else form_data
except Exception as e:
raise DecodeError('Invalid form data: %s' % (str(e))) from e
raise DecodeError('Invalid form data: %s' % (str(e))) from e

return self.schema.load(form_data, unknown=self.unknown)
4 changes: 2 additions & 2 deletions star_resty/payload/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from marshmallow import EXCLUDE, Schema
from starlette.requests import Request

from .parser import Parser, set_parser
from .base import SchemaParser, set_parser

__all__ = ('header', 'header_schema', 'HeaderParser')

Expand All @@ -21,7 +21,7 @@ def header(schema: Union[Schema, Type[Schema]], unknown=EXCLUDE) -> Type[Mapping
return header_schema(schema, Mapping, unknown=unknown)


class HeaderParser(Parser):
class HeaderParser(SchemaParser):
__slots__ = ()

@property
Expand Down
4 changes: 2 additions & 2 deletions star_resty/payload/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from starlette.requests import Request

from star_resty.exceptions import DecodeError
from .parser import Parser, set_parser
from .base import SchemaParser, set_parser

__all__ = ('json_schema', 'json_payload', 'JsonParser')

Expand All @@ -23,7 +23,7 @@ def json_payload(schema: Union[Schema, Type[Schema]], unknown=EXCLUDE) -> Type[M
return json_schema(schema, Mapping, unknown=unknown)


class JsonParser(Parser):
class JsonParser(SchemaParser):
__slots__ = ()

@property
Expand Down
4 changes: 2 additions & 2 deletions star_resty/payload/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from marshmallow import EXCLUDE, Schema
from starlette.requests import Request

from .parser import Parser, set_parser
from .base import SchemaParser, set_parser

__all__ = ('path', 'path_schema', 'PathParser')

Expand All @@ -21,7 +21,7 @@ def path(schema: Union[Schema, Type[Schema]], unknown=EXCLUDE) -> Type[Mapping]:
return path_schema(schema, Mapping, unknown=unknown)


class PathParser(Parser):
class PathParser(SchemaParser):
__slots__ = ()

@property
Expand Down
4 changes: 2 additions & 2 deletions star_resty/payload/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from marshmallow import EXCLUDE, Schema, fields
from starlette.requests import Request

from .parser import Parser, set_parser
from .base import SchemaParser, set_parser

__all__ = ('query', 'query_schema', 'QueryParser')

Expand All @@ -23,7 +23,7 @@ def query(schema: Union[Schema, Type[Schema]], unknown=EXCLUDE) -> Type[Mapping]
return query_schema(schema, Mapping, unknown=unknown)


class QueryParser(Parser):
class QueryParser(SchemaParser):
__slots__ = ('fields',)

@classmethod
Expand Down
76 changes: 76 additions & 0 deletions star_resty/payload/upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import abc
from typing import Optional, Any, Sequence, Mapping, Type

from marshmallow import ValidationError
from starlette.datastructures import UploadFile
from starlette.requests import Request

from .base import Parser

__all__ = ('upload',)


class UploadSequence(Sequence[UploadFile], metaclass=abc.ABCMeta):
pass


def upload(*args: str,
description: Optional[str] = None,
required: bool = False) -> Type[UploadSequence]:
def helper() -> Any:
return UploadParser(args, description=description, required=required)

return helper()


class UploadParser(Parser):

def __init__(self, file_names: Sequence[str] = (), *,
description: Optional[str] = None,
required: bool = False):
self.files_names = frozenset(file_names)
self.description = description
self.required = required

@property
def parser(self):
return self

@property
def media_type(self):
return 'multipart/form-data'

@property
def location(self):
return 'formData'

async def parse(self, request: Request):
form = await request.form()
res = []
for key, val in form.items():
if not isinstance(val, UploadFile):
continue

if not self.files_names or key in self.files_names:
res.append(val)

if self.required and not res:
raise ValidationError(message='Missing required file', field_name='form')

return res

def get_spec(self):
if self.files_names:
for name in sorted(self.files_names):
yield self._create_spec(name)
else:
yield self._create_spec('upfile')

def _create_spec(self, name: str) -> Mapping:
return {
'in': 'formData',
'type': 'file',
'description': self.description or '',
'name': name,
'required': self.required
}

0 comments on commit 43a1b6d

Please sign in to comment.