├── .gitignore ├── .travis.yml ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── openapi_codec ├── __init__.py ├── decode.py ├── encode.py └── utils.py ├── requirements.txt ├── runtests ├── setup.py ├── tests ├── __init__.py ├── petstore.json ├── test_decode.py ├── test_encode.py └── test_mappings.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | env/ 2 | dist/ 3 | htmlcov/ 4 | site/ 5 | .tox/ 6 | *.egg-info/ 7 | *.pyc 8 | __pycache__ 9 | .cache 10 | .coverage 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | 8 | install: 9 | - pip install -r requirements.txt 10 | 11 | script: 12 | - ./runtests 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Copyright © 2016 Tom Christie 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | Redistributions in binary form must reproduce the above copyright notice, this 12 | list of conditions and the following disclaimer in the documentation and/or 13 | other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-exclude __pycache__ 2 | global-exclude *.pyc 3 | global-exclude *.pyo 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAPI Codec 2 | 3 | **An OpenAPI codec for Core API.** 4 | 5 | [![travis-image]][travis] 6 | [![pypi-image]][pypi] 7 | 8 | ## Introduction 9 | 10 | This is a Python [Core API][coreapi] codec for the [Open API][openapi] schema format, also known as "Swagger". 11 | 12 | ## Installation 13 | 14 | Install using pip: 15 | 16 | $ pip install openapi-codec 17 | 18 | ## Creating Swagger schemas 19 | 20 | To create a swagger schema from a `coreapi.Document`, use the codec directly. 21 | 22 | >>> from openapi_codec import OpenAPICodec 23 | >>> codec = OpenAPICodec() 24 | >>> schema = codec.encode(document) 25 | 26 | ## Using with the Python Client Library 27 | 28 | Install `coreapi` and the `openapi-codec`. 29 | 30 | $ pip install coreapi 31 | $ pip install openapi-codec 32 | 33 | To use the Python client library to interact with a service that exposes a Swagger schema, 34 | include the codec in [the `decoders` argument][decoders]. 35 | 36 | >>> from openapi_codec import OpenAPICodec 37 | >>> from coreapi.codecs import JSONCodec 38 | >>> from coreapi import Client 39 | >>> decoders = [OpenAPICodec(), JSONCodec()] 40 | >>> client = Client(decoders=decoders) 41 | 42 | If the server exposes the schema without properly using an `application/openapi+json` content type, then you'll need to make sure to include `format='openapi'` on the initial request, 43 | to force the correct codec to be used. 44 | 45 | >>> schema = client.get('http://petstore.swagger.io/v2/swagger.json', format='openapi') 46 | 47 | At this point you can now start to interact with the API: 48 | 49 | >>> client.action(schema, ['pet', 'addPet'], params={'photoUrls': [], 'name': 'fluffy'}) 50 | 51 | ## Using with the Command Line Client 52 | 53 | Once the `openapi-codec` package is installed, the codec will automatically become available to the command line client. 54 | 55 | $ pip install coreapi-cli 56 | $ pip install openapi-codec 57 | $ coreapi codecs show 58 | Codec name Media type Support Package 59 | corejson | application/coreapi+json | encoding, decoding | coreapi==2.0.7 60 | openapi | application/openapi+json | encoding, decoding | openapi-codec==1.1.0 61 | json | application/json | decoding | coreapi==2.0.7 62 | text | text/* | decoding | coreapi==2.0.7 63 | download | */* | decoding | coreapi==2.0.7 64 | 65 | If the server exposes the schema without properly using an `application/openapi+json` content type, then you'll need to make sure to include `format=openapi` on the initial request, to force the correct codec to be used. 66 | 67 | $ coreapi get http://petstore.swagger.io/v2/swagger.json --format openapi 68 | 69 | pet: { 70 | addPet(photoUrls, name, [status], [id], [category], [tags]) 71 | deletePet(petId, [api_key]) 72 | findPetsByStatus(status) 73 | ... 74 | 75 | At this point you can start to interact with the API. 76 | 77 | $ coreapi action pet addPet --param name=fluffy --param photoUrls=[] 78 | { 79 | "id": 201609262739, 80 | "name": "fluffy", 81 | "photoUrls": [], 82 | "tags": [] 83 | } 84 | 85 | Use the `--debug` flag to see the full HTTP request and response. 86 | 87 | $ coreapi action pet addPet --param name=fluffy --param photoUrls=[] --debug 88 | > POST /v2/pet HTTP/1.1 89 | > Accept-Encoding: gzip, deflate 90 | > Connection: keep-alive 91 | > Content-Length: 35 92 | > Content-Type: application/json 93 | > Accept: application/coreapi+json, */* 94 | > Host: petstore.swagger.io 95 | > User-Agent: coreapi 96 | > 97 | > {"photoUrls": [], "name": "fluffy"} 98 | < 200 OK 99 | < Access-Control-Allow-Headers: Content-Type, api_key, Authorization 100 | < Access-Control-Allow-Methods: GET, POST, DELETE, PUT 101 | < Access-Control-Allow-Origin: * 102 | < Connection: close 103 | < Content-Type: application/json 104 | < Date: Mon, 26 Sep 2016 13:17:33 GMT 105 | < Server: Jetty(9.2.9.v20150224) 106 | < 107 | < {"id":201609262739,"name":"fluffy","photoUrls":[],"tags":[]} 108 | 109 | { 110 | "id": 201609262739, 111 | "name": "fluffy", 112 | "photoUrls": [], 113 | "tags": [] 114 | } 115 | 116 | [travis-image]: https://secure.travis-ci.org/core-api/python-openapi-codec.svg?branch=master 117 | [travis]: http://travis-ci.org/core-api/python-openapi-codec?branch=master 118 | [pypi-image]: https://img.shields.io/pypi/v/openapi-codec.svg 119 | [pypi]: https://pypi.python.org/pypi/openapi-codec 120 | 121 | [coreapi]: http://www.coreapi.org/ 122 | [openapi]: https://openapis.org/ 123 | [decoders]: http://core-api.github.io/python-client/api-guide/client/#instantiating-a-client 124 | -------------------------------------------------------------------------------- /openapi_codec/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from coreapi.codecs.base import BaseCodec 4 | from coreapi.compat import force_bytes 5 | from coreapi.document import Document 6 | from coreapi.exceptions import ParseError 7 | from openapi_codec.encode import generate_swagger_object 8 | from openapi_codec.decode import _parse_document 9 | 10 | 11 | __version__ = '1.3.2' 12 | 13 | 14 | class OpenAPICodec(BaseCodec): 15 | media_type = 'application/openapi+json' 16 | format = 'openapi' 17 | 18 | def decode(self, bytes, **options): 19 | """ 20 | Takes a bytestring and returns a document. 21 | """ 22 | try: 23 | data = json.loads(bytes.decode('utf-8')) 24 | except ValueError as exc: 25 | raise ParseError('Malformed JSON. %s' % exc) 26 | 27 | base_url = options.get('base_url') 28 | doc = _parse_document(data, base_url) 29 | if not isinstance(doc, Document): 30 | raise ParseError('Top level node must be a document.') 31 | 32 | return doc 33 | 34 | def encode(self, document, **options): 35 | if not isinstance(document, Document): 36 | raise TypeError('Expected a `coreapi.Document` instance') 37 | data = generate_swagger_object(document) 38 | return force_bytes(json.dumps(data)) 39 | -------------------------------------------------------------------------------- /openapi_codec/decode.py: -------------------------------------------------------------------------------- 1 | from coreapi import Document, Link, Field 2 | from coreapi.compat import string_types, urlparse 3 | from coreapi.exceptions import ParseError 4 | import coreschema 5 | 6 | 7 | def _parse_document(data, base_url=None): 8 | schema_url = base_url 9 | base_url = _get_document_base_url(data, base_url) 10 | info = _get_dict(data, 'info') 11 | title = _get_string(info, 'title') 12 | description = _get_string(info, 'description') 13 | consumes = get_strings(_get_list(data, 'consumes')) 14 | paths = _get_dict(data, 'paths') 15 | content = {} 16 | for path in paths.keys(): 17 | url = base_url + path.lstrip('/') 18 | spec = _get_dict(paths, path) 19 | default_parameters = get_dicts(_get_list(spec, 'parameters')) 20 | for action in spec.keys(): 21 | action = action.lower() 22 | if action not in ('get', 'put', 'post', 'delete', 'options', 'head', 'patch'): 23 | continue 24 | operation = _get_dict(spec, action) 25 | 26 | # Determine any fields on the link. 27 | has_body = False 28 | has_form = False 29 | 30 | fields = [] 31 | parameters = get_dicts(_get_list(operation, 'parameters', default_parameters), dereference_using=data) 32 | for parameter in parameters: 33 | name = _get_string(parameter, 'name') 34 | location = _get_string(parameter, 'in') 35 | required = _get_bool(parameter, 'required', default=(location == 'path')) 36 | if location == 'body': 37 | has_body = True 38 | schema = _get_dict(parameter, 'schema', dereference_using=data) 39 | expanded = _expand_schema(schema) 40 | if expanded is not None: 41 | # TODO: field schemas. 42 | expanded_fields = [ 43 | Field( 44 | name=field_name, 45 | location='form', 46 | required=is_required, 47 | schema=coreschema.String(description=field_description) 48 | ) 49 | for field_name, is_required, field_description in expanded 50 | if not any([field.name == field_name for field in fields]) 51 | ] 52 | fields += expanded_fields 53 | else: 54 | # TODO: field schemas. 55 | field_description = _get_string(parameter, 'description') 56 | field = Field( 57 | name=name, 58 | location='body', 59 | required=required, 60 | schema=coreschema.String(description=field_description) 61 | ) 62 | fields.append(field) 63 | else: 64 | if location == 'formData': 65 | has_form = True 66 | location = 'form' 67 | field_description = _get_string(parameter, 'description') 68 | # TODO: field schemas. 69 | field = Field( 70 | name=name, 71 | location=location, 72 | required=required, 73 | schema=coreschema.String(description=field_description) 74 | ) 75 | fields.append(field) 76 | 77 | link_consumes = get_strings(_get_list(operation, 'consumes', consumes)) 78 | encoding = '' 79 | if has_body: 80 | encoding = _select_encoding(link_consumes) 81 | elif has_form: 82 | encoding = _select_encoding(link_consumes, form=True) 83 | 84 | link_title = _get_string(operation, 'summary') 85 | link_description = _get_string(operation, 'description') 86 | link = Link(url=url, action=action, encoding=encoding, fields=fields, title=link_title, description=link_description) 87 | 88 | # Add the link to the document content. 89 | tags = get_strings(_get_list(operation, 'tags')) 90 | operation_id = _get_string(operation, 'operationId') 91 | if tags: 92 | tag = tags[0] 93 | prefix = tag + '_' 94 | if operation_id.startswith(prefix): 95 | operation_id = operation_id[len(prefix):] 96 | if tag not in content: 97 | content[tag] = {} 98 | content[tag][operation_id] = link 99 | else: 100 | content[operation_id] = link 101 | 102 | return Document( 103 | url=schema_url, 104 | title=title, 105 | description=description, 106 | content=content, 107 | media_type='application/openapi+json' 108 | ) 109 | 110 | 111 | def _get_document_base_url(data, base_url=None): 112 | """ 113 | Get the base url to use when constructing absolute paths from the 114 | relative ones provided in the schema defination. 115 | """ 116 | prefered_schemes = ['https', 'http'] 117 | if base_url: 118 | url_components = urlparse.urlparse(base_url) 119 | default_host = url_components.netloc 120 | default_scheme = url_components.scheme 121 | else: 122 | default_host = '' 123 | default_scheme = None 124 | 125 | host = _get_string(data, 'host', default=default_host) 126 | path = _get_string(data, 'basePath', default='/') 127 | path = '/' + path.lstrip('/') 128 | path = path.rstrip('/') + '/' 129 | 130 | if not host: 131 | # No host is provided, and we do not have an initial URL. 132 | return path 133 | 134 | schemes = _get_list(data, 'schemes') 135 | 136 | if not schemes: 137 | # No schemes provided, use the initial URL, or a fallback. 138 | scheme = default_scheme or prefered_schemes[0] 139 | elif default_scheme in schemes: 140 | # Schemes provided, the initial URL matches one of them. 141 | scheme = default_scheme 142 | else: 143 | # Schemes provided, the initial URL does not match, pick a fallback. 144 | for scheme in prefered_schemes: 145 | if scheme in schemes: 146 | break 147 | else: 148 | raise ParseError('Unsupported transport schemes "%s"' % schemes) 149 | 150 | return '%s://%s%s' % (scheme, host, path) 151 | 152 | 153 | def _select_encoding(consumes, form=False): 154 | """ 155 | Given an OpenAPI 'consumes' list, return a single 'encoding' for CoreAPI. 156 | """ 157 | if form: 158 | preference = [ 159 | 'multipart/form-data', 160 | 'application/x-www-form-urlencoded', 161 | 'application/json' 162 | ] 163 | else: 164 | preference = [ 165 | 'application/json', 166 | 'multipart/form-data', 167 | 'application/x-www-form-urlencoded', 168 | 'application/octet-stream' 169 | ] 170 | 171 | if not consumes: 172 | return preference[0] 173 | 174 | for media_type in preference: 175 | if media_type in consumes: 176 | return media_type 177 | 178 | return consumes[0] 179 | 180 | 181 | def _expand_schema(schema): 182 | """ 183 | When an OpenAPI parameter uses `in="body"`, and the schema type is "object", 184 | then we expand out the parameters of the object into individual fields. 185 | """ 186 | schema_type = schema.get('type') 187 | schema_properties = _get_dict(schema, 'properties') 188 | schema_required = _get_list(schema, 'required') 189 | if ((schema_type == ['object']) or (schema_type == 'object')) and schema_properties: 190 | return [ 191 | (key, key in schema_required, schema_properties[key].get('description')) 192 | for key in schema_properties.keys() 193 | ] 194 | return None 195 | 196 | 197 | # Helper functions to get an expected type from a dictionary. 198 | 199 | def dereference(lookup_string, struct): 200 | """ 201 | Dereference a JSON pointer. 202 | http://tools.ietf.org/html/rfc6901 203 | """ 204 | keys = lookup_string.strip('#/').split('/') 205 | node = struct 206 | for key in keys: 207 | node = _get_dict(node, key) 208 | return node 209 | 210 | 211 | def is_json_pointer(value): 212 | return isinstance(value, dict) and ('$ref' in value) and (len(value) == 1) 213 | 214 | 215 | def _get_string(item, key, default=''): 216 | value = item.get(key) 217 | return value if isinstance(value, string_types) else default 218 | 219 | 220 | def _get_dict(item, key, default={}, dereference_using=None): 221 | value = item.get(key) 222 | if isinstance(value, dict): 223 | if dereference_using and is_json_pointer(value): 224 | return dereference(value['$ref'], dereference_using) 225 | return value 226 | return default.copy() 227 | 228 | 229 | def _get_list(item, key, default=[]): 230 | value = item.get(key) 231 | return value if isinstance(value, list) else list(default) 232 | 233 | 234 | def _get_bool(item, key, default=False): 235 | value = item.get(key) 236 | return value if isinstance(value, bool) else default 237 | 238 | 239 | # Helper functions to get an expected type from a list. 240 | 241 | def get_dicts(item, dereference_using=None): 242 | ret = [value for value in item if isinstance(value, dict)] 243 | if dereference_using: 244 | return [ 245 | dereference(value['$ref'], dereference_using) if is_json_pointer(value) else value 246 | for value in ret 247 | ] 248 | return ret 249 | 250 | 251 | def get_strings(item): 252 | return [value for value in item if isinstance(value, string_types)] 253 | -------------------------------------------------------------------------------- /openapi_codec/encode.py: -------------------------------------------------------------------------------- 1 | import coreschema 2 | from collections import OrderedDict 3 | from coreapi.compat import urlparse 4 | from openapi_codec.utils import get_method, get_encoding, get_location, get_links_from_document 5 | 6 | 7 | def generate_swagger_object(document): 8 | """ 9 | Generates root of the Swagger spec. 10 | """ 11 | parsed_url = urlparse.urlparse(document.url) 12 | 13 | swagger = OrderedDict() 14 | 15 | swagger['swagger'] = '2.0' 16 | swagger['info'] = OrderedDict() 17 | swagger['info']['title'] = document.title 18 | swagger['info']['description'] = document.description 19 | swagger['info']['version'] = '' # Required by the spec 20 | 21 | if parsed_url.netloc: 22 | swagger['host'] = parsed_url.netloc 23 | if parsed_url.scheme: 24 | swagger['schemes'] = [parsed_url.scheme] 25 | 26 | swagger['paths'] = _get_paths_object(document) 27 | 28 | return swagger 29 | 30 | 31 | def _add_tag_prefix(item): 32 | operation_id, link, tags = item 33 | if tags: 34 | operation_id = tags[0] + '_' + operation_id 35 | return (operation_id, link, tags) 36 | 37 | 38 | def _get_links(document): 39 | """ 40 | Return a list of (operation_id, link, [tags]) 41 | """ 42 | # Extract all the links from the first or second level of the document. 43 | links = [] 44 | for keys, link in get_links_from_document(document): 45 | if len(keys) > 1: 46 | operation_id = '_'.join(keys[1:]) 47 | tags = [keys[0]] 48 | else: 49 | operation_id = keys[0] 50 | tags = [] 51 | links.append((operation_id, link, tags)) 52 | 53 | # Determine if the operation ids each have unique names or not. 54 | operation_ids = [item[0] for item in links] 55 | unique = len(set(operation_ids)) == len(links) 56 | 57 | # If the operation ids are not unique, then prefix them with the tag. 58 | if not unique: 59 | return [_add_tag_prefix(item) for item in links] 60 | 61 | return links 62 | 63 | 64 | def _get_paths_object(document): 65 | paths = OrderedDict() 66 | 67 | links = _get_links(document) 68 | 69 | for operation_id, link, tags in links: 70 | if link.url not in paths: 71 | paths[link.url] = OrderedDict() 72 | 73 | method = get_method(link) 74 | operation = _get_operation(operation_id, link, tags) 75 | paths[link.url].update({method: operation}) 76 | 77 | return paths 78 | 79 | 80 | def _get_operation(operation_id, link, tags): 81 | encoding = get_encoding(link) 82 | description = link.description.strip() 83 | summary = description.splitlines()[0] if description else None 84 | 85 | operation = { 86 | 'operationId': operation_id, 87 | 'responses': _get_responses(link), 88 | 'parameters': _get_parameters(link, encoding) 89 | } 90 | 91 | if description: 92 | operation['description'] = description 93 | if summary: 94 | operation['summary'] = summary 95 | if encoding: 96 | operation['consumes'] = [encoding] 97 | if tags: 98 | operation['tags'] = tags 99 | return operation 100 | 101 | 102 | def _get_field_description(field): 103 | if getattr(field, 'description', None) is not None: 104 | # Deprecated 105 | return field.description 106 | 107 | if field.schema is None: 108 | return '' 109 | 110 | return field.schema.description 111 | 112 | 113 | def _get_field_type(field): 114 | if getattr(field, 'type', None) is not None: 115 | # Deprecated 116 | return field.type 117 | 118 | if field.schema is None: 119 | return 'string' 120 | 121 | return { 122 | coreschema.String: 'string', 123 | coreschema.Integer: 'integer', 124 | coreschema.Number: 'number', 125 | coreschema.Boolean: 'boolean', 126 | coreschema.Array: 'array', 127 | coreschema.Object: 'object', 128 | }.get(field.schema.__class__, 'string') 129 | 130 | 131 | def _get_parameters(link, encoding): 132 | """ 133 | Generates Swagger Parameter Item object. 134 | """ 135 | parameters = [] 136 | properties = {} 137 | required = [] 138 | 139 | for field in link.fields: 140 | location = get_location(link, field) 141 | field_description = _get_field_description(field) 142 | field_type = _get_field_type(field) 143 | if location == 'form': 144 | if encoding in ('multipart/form-data', 'application/x-www-form-urlencoded'): 145 | # 'formData' in swagger MUST be one of these media types. 146 | parameter = { 147 | 'name': field.name, 148 | 'required': field.required, 149 | 'in': 'formData', 150 | 'description': field_description, 151 | 'type': field_type, 152 | } 153 | if field_type == 'array': 154 | parameter['items'] = {'type': 'string'} 155 | parameters.append(parameter) 156 | else: 157 | # Expand coreapi fields with location='form' into a single swagger 158 | # parameter, with a schema containing multiple properties. 159 | 160 | schema_property = { 161 | 'description': field_description, 162 | 'type': field_type, 163 | } 164 | if field_type == 'array': 165 | schema_property['items'] = {'type': 'string'} 166 | properties[field.name] = schema_property 167 | if field.required: 168 | required.append(field.name) 169 | elif location == 'body': 170 | if encoding == 'application/octet-stream': 171 | # https://github.com/OAI/OpenAPI-Specification/issues/50#issuecomment-112063782 172 | schema = {'type': 'string', 'format': 'binary'} 173 | else: 174 | schema = {} 175 | parameter = { 176 | 'name': field.name, 177 | 'required': field.required, 178 | 'in': location, 179 | 'description': field_description, 180 | 'schema': schema 181 | } 182 | parameters.append(parameter) 183 | else: 184 | parameter = { 185 | 'name': field.name, 186 | 'required': field.required, 187 | 'in': location, 188 | 'description': field_description, 189 | 'type': field_type or 'string', 190 | } 191 | if field_type == 'array': 192 | parameter['items'] = {'type': 'string'} 193 | parameters.append(parameter) 194 | 195 | if properties: 196 | parameter = { 197 | 'name': 'data', 198 | 'in': 'body', 199 | 'schema': { 200 | 'type': 'object', 201 | 'properties': properties 202 | } 203 | } 204 | if required: 205 | parameter['schema']['required'] = required 206 | parameters.append(parameter) 207 | 208 | return parameters 209 | 210 | 211 | def _get_responses(link): 212 | """ 213 | Returns minimally acceptable responses object based 214 | on action / method type. 215 | """ 216 | template = {'description': ''} 217 | if link.action.lower() == 'post': 218 | return {'201': template} 219 | if link.action.lower() == 'delete': 220 | return {'204': template} 221 | return {'200': template} 222 | -------------------------------------------------------------------------------- /openapi_codec/utils.py: -------------------------------------------------------------------------------- 1 | def link_sorting_key(link_item): 2 | keys, link = link_item 3 | action_priority = { 4 | '': 0, 'get': 0, 5 | 'post': 1, 6 | 'put': 2, 7 | 'patch': 3, 8 | 'delete': 4 9 | }.get(link.action, 5) 10 | return (link.url, action_priority) 11 | 12 | 13 | def get_links_from_document(node, keys=()): 14 | links = [] 15 | for key, link in getattr(node, 'links', {}).items(): 16 | # Get all the resources at this level 17 | index = keys + (key,) 18 | links.append((index, link)) 19 | for key, child in getattr(node, 'data', {}).items(): 20 | # Descend into any nested structures. 21 | index = keys + (key,) 22 | links.extend(get_links_from_document(child, index)) 23 | return sorted(links, key=link_sorting_key) 24 | 25 | 26 | def get_method(link): 27 | method = link.action.lower() 28 | if not method: 29 | method = 'get' 30 | return method 31 | 32 | 33 | def get_encoding(link): 34 | encoding = link.encoding 35 | has_body = any([get_location(link, field) in ('form', 'body') for field in link.fields]) 36 | if not encoding and has_body: 37 | encoding = 'application/json' 38 | elif encoding and not has_body: 39 | encoding = '' 40 | return encoding 41 | 42 | 43 | def get_location(link, field): 44 | location = field.location 45 | if not location: 46 | if get_method(link) in ('get', 'delete'): 47 | location = 'query' 48 | else: 49 | location = 'form' 50 | return location 51 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Package requirements 2 | coreapi 3 | 4 | # Testing requirements 5 | coverage 6 | flake8 7 | pytest 8 | -------------------------------------------------------------------------------- /runtests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import coverage 3 | import os 4 | import pytest 5 | import subprocess 6 | import sys 7 | 8 | 9 | PYTEST_ARGS = ['tests', '--tb=short'] 10 | FLAKE8_ARGS = ['openapi_codec', 'tests', '--ignore=E501'] 11 | COVERAGE_OPTIONS = { 12 | 'include': ['openapi_codec/*', 'tests/*'] 13 | } 14 | 15 | 16 | sys.path.append(os.path.dirname(__file__)) 17 | 18 | 19 | class NullFile(object): 20 | def write(self, data): 21 | pass 22 | 23 | 24 | def exit_on_failure(ret, message=None): 25 | if ret: 26 | sys.exit(ret) 27 | 28 | 29 | def flake8_main(args): 30 | print('Running flake8 code linting') 31 | ret = subprocess.call(['flake8'] + args) 32 | print('flake8 failed' if ret else 'flake8 passed') 33 | return ret 34 | 35 | 36 | def report_coverage(cov, fail_if_not_100=False): 37 | precent_covered = cov.report( 38 | file=NullFile(), **COVERAGE_OPTIONS 39 | ) 40 | if precent_covered == 100: 41 | print('100% coverage') 42 | return 43 | if fail_if_not_100: 44 | print('Tests passed, but not 100% coverage.') 45 | cov.report(**COVERAGE_OPTIONS) 46 | cov.html_report(**COVERAGE_OPTIONS) 47 | if fail_if_not_100: 48 | sys.exit(1) 49 | 50 | 51 | def split_class_and_function(string): 52 | class_string, function_string = string.split('.', 1) 53 | return "%s and %s" % (class_string, function_string) 54 | 55 | 56 | def is_function(string): 57 | # `True` if it looks like a test function is included in the string. 58 | return string.startswith('test_') or '.test_' in string 59 | 60 | 61 | def is_class(string): 62 | # `True` if first character is uppercase - assume it's a class name. 63 | return string[0] == string[0].upper() 64 | 65 | 66 | if __name__ == "__main__": 67 | if len(sys.argv) > 1: 68 | pytest_args = sys.argv[1:] 69 | first_arg = pytest_args[0] 70 | if first_arg.startswith('-'): 71 | # `runtests.py [flags]` 72 | pytest_args = PYTEST_ARGS + pytest_args 73 | elif is_class(first_arg) and is_function(first_arg): 74 | # `runtests.py TestCase.test_function [flags]` 75 | expression = split_class_and_function(first_arg) 76 | pytest_args = PYTEST_ARGS + ['-k', expression] + pytest_args[1:] 77 | elif is_class(first_arg) or is_function(first_arg): 78 | # `runtests.py TestCase [flags]` 79 | # `runtests.py test_function [flags]` 80 | pytest_args = PYTEST_ARGS + ['-k', pytest_args[0]] + pytest_args[1:] 81 | else: 82 | pytest_args = PYTEST_ARGS 83 | 84 | cov = coverage.coverage() 85 | cov.start() 86 | exit_on_failure(pytest.main(pytest_args)) 87 | cov.stop() 88 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 89 | report_coverage(cov) 90 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | import re 6 | import os 7 | import sys 8 | 9 | 10 | def get_version(package): 11 | """ 12 | Return package version as listed in `__version__` in `init.py`. 13 | """ 14 | init_py = open(os.path.join(package, '__init__.py')).read() 15 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 16 | 17 | 18 | def get_packages(package): 19 | """ 20 | Return root package and all sub-packages. 21 | """ 22 | return [dirpath 23 | for dirpath, dirnames, filenames in os.walk(package) 24 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 25 | 26 | 27 | def get_package_data(package): 28 | """ 29 | Return all files under the root package, that are not in a 30 | package themselves. 31 | """ 32 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 33 | for dirpath, dirnames, filenames in os.walk(package) 34 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 35 | 36 | filepaths = [] 37 | for base, filenames in walk: 38 | filepaths.extend([os.path.join(base, filename) 39 | for filename in filenames]) 40 | return {package: filepaths} 41 | 42 | 43 | version = get_version('openapi_codec') 44 | 45 | 46 | if sys.argv[-1] == 'publish': 47 | os.system("python setup.py sdist upload") 48 | print("You probably want to also tag the version now:") 49 | print(" git tag -a %s -m 'version %s'" % (version, version)) 50 | print(" git push --tags") 51 | sys.exit() 52 | 53 | 54 | setup( 55 | name='openapi-codec', 56 | version=version, 57 | url='http://github.com/core-api/python-openapi-codec/', 58 | license='BSD', 59 | description='An OpenAPI codec for Core API.', 60 | author='Tom Christie', 61 | author_email='tom@tomchristie.com', 62 | packages=get_packages('openapi_codec'), 63 | package_data=get_package_data('openapi_codec'), 64 | install_requires=['coreapi>=2.2.0'], 65 | classifiers=[ 66 | 'Intended Audience :: Developers', 67 | 'License :: OSI Approved :: BSD License', 68 | 'Operating System :: OS Independent', 69 | 'Programming Language :: Python', 70 | 'Programming Language :: Python :: 3', 71 | ], 72 | entry_points={ 73 | 'coreapi.codecs': [ 74 | 'openapi=openapi_codec:OpenAPICodec' 75 | ] 76 | } 77 | ) 78 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/core-api/python-openapi-codec/baa97e0a0dc0651fd27fc00948da0f6369ee1630/tests/__init__.py -------------------------------------------------------------------------------- /tests/petstore.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "paths": { 4 | "/pet/{petId}/uploadImage": { 5 | "post": { 6 | "operationId": "uploadFile", 7 | "consumes": [ 8 | "multipart/form-data" 9 | ], 10 | "summary": "uploads an image", 11 | "security": [ 12 | { 13 | "petstore_auth": [ 14 | "write:pets", 15 | "read:pets" 16 | ] 17 | } 18 | ], 19 | "responses": { 20 | "200": { 21 | "description": "successful operation", 22 | "schema": { 23 | "$ref": "#/definitions/ApiResponse" 24 | } 25 | } 26 | }, 27 | "description": "", 28 | "produces": [ 29 | "application/json" 30 | ], 31 | "parameters": [ 32 | { 33 | "type": "integer", 34 | "required": true, 35 | "description": "ID of pet to update", 36 | "name": "petId", 37 | "format": "int64", 38 | "in": "path" 39 | }, 40 | { 41 | "description": "Additional data to pass to server", 42 | "name": "additionalMetadata", 43 | "required": false, 44 | "in": "formData", 45 | "type": "string" 46 | }, 47 | { 48 | "description": "file to upload", 49 | "name": "file", 50 | "required": false, 51 | "in": "formData", 52 | "type": "file" 53 | } 54 | ], 55 | "tags": [ 56 | "pet" 57 | ] 58 | } 59 | }, 60 | "/store/order": { 61 | "post": { 62 | "operationId": "placeOrder", 63 | "summary": "Place an order for a pet", 64 | "responses": { 65 | "400": { 66 | "description": "Invalid Order" 67 | }, 68 | "200": { 69 | "description": "successful operation", 70 | "schema": { 71 | "$ref": "#/definitions/Order" 72 | } 73 | } 74 | }, 75 | "description": "", 76 | "produces": [ 77 | "application/xml", 78 | "application/json" 79 | ], 80 | "parameters": [ 81 | { 82 | "description": "order placed for purchasing the pet", 83 | "name": "body", 84 | "required": true, 85 | "in": "body", 86 | "schema": { 87 | "$ref": "#/definitions/Order" 88 | } 89 | } 90 | ], 91 | "tags": [ 92 | "store" 93 | ] 94 | } 95 | }, 96 | "/pet/findByStatus": { 97 | "get": { 98 | "operationId": "findPetsByStatus", 99 | "security": [ 100 | { 101 | "petstore_auth": [ 102 | "write:pets", 103 | "read:pets" 104 | ] 105 | } 106 | ], 107 | "summary": "Finds Pets by status", 108 | "responses": { 109 | "400": { 110 | "description": "Invalid status value" 111 | }, 112 | "200": { 113 | "description": "successful operation", 114 | "schema": { 115 | "type": "array", 116 | "items": { 117 | "$ref": "#/definitions/Pet" 118 | } 119 | } 120 | } 121 | }, 122 | "description": "Multiple status values can be provided with comma separated strings", 123 | "produces": [ 124 | "application/xml", 125 | "application/json" 126 | ], 127 | "parameters": [ 128 | { 129 | "type": "array", 130 | "required": true, 131 | "items": { 132 | "type": "string", 133 | "enum": [ 134 | "available", 135 | "pending", 136 | "sold" 137 | ], 138 | "default": "available" 139 | }, 140 | "description": "Status values that need to be considered for filter", 141 | "name": "status", 142 | "collectionFormat": "multi", 143 | "in": "query" 144 | } 145 | ], 146 | "tags": [ 147 | "pet" 148 | ] 149 | } 150 | }, 151 | "/pet/findByTags": { 152 | "get": { 153 | "operationId": "findPetsByTags", 154 | "security": [ 155 | { 156 | "petstore_auth": [ 157 | "write:pets", 158 | "read:pets" 159 | ] 160 | } 161 | ], 162 | "summary": "Finds Pets by tags", 163 | "deprecated": true, 164 | "responses": { 165 | "400": { 166 | "description": "Invalid tag value" 167 | }, 168 | "200": { 169 | "description": "successful operation", 170 | "schema": { 171 | "type": "array", 172 | "items": { 173 | "$ref": "#/definitions/Pet" 174 | } 175 | } 176 | } 177 | }, 178 | "description": "Muliple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", 179 | "produces": [ 180 | "application/xml", 181 | "application/json" 182 | ], 183 | "parameters": [ 184 | { 185 | "type": "array", 186 | "required": true, 187 | "items": { 188 | "type": "string" 189 | }, 190 | "description": "Tags to filter by", 191 | "name": "tags", 192 | "collectionFormat": "multi", 193 | "in": "query" 194 | } 195 | ], 196 | "tags": [ 197 | "pet" 198 | ] 199 | } 200 | }, 201 | "/pet": { 202 | "put": { 203 | "operationId": "updatePet", 204 | "consumes": [ 205 | "application/json", 206 | "application/xml" 207 | ], 208 | "summary": "Update an existing pet", 209 | "security": [ 210 | { 211 | "petstore_auth": [ 212 | "write:pets", 213 | "read:pets" 214 | ] 215 | } 216 | ], 217 | "responses": { 218 | "400": { 219 | "description": "Invalid ID supplied" 220 | }, 221 | "405": { 222 | "description": "Validation exception" 223 | }, 224 | "404": { 225 | "description": "Pet not found" 226 | } 227 | }, 228 | "description": "", 229 | "produces": [ 230 | "application/xml", 231 | "application/json" 232 | ], 233 | "parameters": [ 234 | { 235 | "description": "Pet object that needs to be added to the store", 236 | "name": "body", 237 | "required": true, 238 | "in": "body", 239 | "schema": { 240 | "$ref": "#/definitions/Pet" 241 | } 242 | } 243 | ], 244 | "tags": [ 245 | "pet" 246 | ] 247 | }, 248 | "post": { 249 | "operationId": "addPet", 250 | "consumes": [ 251 | "application/json", 252 | "application/xml" 253 | ], 254 | "summary": "Add a new pet to the store", 255 | "security": [ 256 | { 257 | "petstore_auth": [ 258 | "write:pets", 259 | "read:pets" 260 | ] 261 | } 262 | ], 263 | "responses": { 264 | "405": { 265 | "description": "Invalid input" 266 | } 267 | }, 268 | "description": "", 269 | "produces": [ 270 | "application/xml", 271 | "application/json" 272 | ], 273 | "parameters": [ 274 | { 275 | "description": "Pet object that needs to be added to the store", 276 | "name": "body", 277 | "required": true, 278 | "in": "body", 279 | "schema": { 280 | "$ref": "#/definitions/Pet" 281 | } 282 | } 283 | ], 284 | "tags": [ 285 | "pet" 286 | ] 287 | } 288 | }, 289 | "/user/createWithList": { 290 | "post": { 291 | "operationId": "createUsersWithListInput", 292 | "summary": "Creates list of users with given input array", 293 | "responses": { 294 | "default": { 295 | "description": "successful operation" 296 | } 297 | }, 298 | "description": "", 299 | "produces": [ 300 | "application/xml", 301 | "application/json" 302 | ], 303 | "parameters": [ 304 | { 305 | "description": "List of user object", 306 | "name": "body", 307 | "required": true, 308 | "in": "body", 309 | "schema": { 310 | "type": "array", 311 | "items": { 312 | "$ref": "#/definitions/User" 313 | } 314 | } 315 | } 316 | ], 317 | "tags": [ 318 | "user" 319 | ] 320 | } 321 | }, 322 | "/user": { 323 | "post": { 324 | "operationId": "createUser", 325 | "summary": "Create user", 326 | "responses": { 327 | "default": { 328 | "description": "successful operation" 329 | } 330 | }, 331 | "description": "This can only be done by the logged in user.", 332 | "produces": [ 333 | "application/xml", 334 | "application/json" 335 | ], 336 | "parameters": [ 337 | { 338 | "description": "Created user object", 339 | "name": "body", 340 | "required": true, 341 | "in": "body", 342 | "schema": { 343 | "$ref": "#/definitions/User" 344 | } 345 | } 346 | ], 347 | "tags": [ 348 | "user" 349 | ] 350 | } 351 | }, 352 | "/user/{username}": { 353 | "put": { 354 | "operationId": "updateUser", 355 | "summary": "Updated user", 356 | "responses": { 357 | "400": { 358 | "description": "Invalid user supplied" 359 | }, 360 | "404": { 361 | "description": "User not found" 362 | } 363 | }, 364 | "description": "This can only be done by the logged in user.", 365 | "produces": [ 366 | "application/xml", 367 | "application/json" 368 | ], 369 | "parameters": [ 370 | { 371 | "description": "name that need to be updated", 372 | "name": "username", 373 | "required": true, 374 | "in": "path", 375 | "type": "string" 376 | }, 377 | { 378 | "description": "Updated user object", 379 | "name": "body", 380 | "required": true, 381 | "in": "body", 382 | "schema": { 383 | "$ref": "#/definitions/User" 384 | } 385 | } 386 | ], 387 | "tags": [ 388 | "user" 389 | ] 390 | }, 391 | "delete": { 392 | "operationId": "deleteUser", 393 | "summary": "Delete user", 394 | "responses": { 395 | "400": { 396 | "description": "Invalid username supplied" 397 | }, 398 | "404": { 399 | "description": "User not found" 400 | } 401 | }, 402 | "description": "This can only be done by the logged in user.", 403 | "produces": [ 404 | "application/xml", 405 | "application/json" 406 | ], 407 | "parameters": [ 408 | { 409 | "description": "The name that needs to be deleted", 410 | "name": "username", 411 | "required": true, 412 | "in": "path", 413 | "type": "string" 414 | } 415 | ], 416 | "tags": [ 417 | "user" 418 | ] 419 | }, 420 | "get": { 421 | "operationId": "getUserByName", 422 | "summary": "Get user by user name", 423 | "responses": { 424 | "400": { 425 | "description": "Invalid username supplied" 426 | }, 427 | "200": { 428 | "description": "successful operation", 429 | "schema": { 430 | "$ref": "#/definitions/User" 431 | } 432 | }, 433 | "404": { 434 | "description": "User not found" 435 | } 436 | }, 437 | "description": "", 438 | "produces": [ 439 | "application/xml", 440 | "application/json" 441 | ], 442 | "parameters": [ 443 | { 444 | "description": "The name that needs to be fetched. Use user1 for testing. ", 445 | "name": "username", 446 | "required": true, 447 | "in": "path", 448 | "type": "string" 449 | } 450 | ], 451 | "tags": [ 452 | "user" 453 | ] 454 | } 455 | }, 456 | "/store/order/{orderId}": { 457 | "delete": { 458 | "operationId": "deleteOrder", 459 | "summary": "Delete purchase order by ID", 460 | "responses": { 461 | "400": { 462 | "description": "Invalid ID supplied" 463 | }, 464 | "404": { 465 | "description": "Order not found" 466 | } 467 | }, 468 | "description": "For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors", 469 | "produces": [ 470 | "application/xml", 471 | "application/json" 472 | ], 473 | "parameters": [ 474 | { 475 | "type": "integer", 476 | "required": true, 477 | "minimum": 1.0, 478 | "description": "ID of the order that needs to be deleted", 479 | "name": "orderId", 480 | "format": "int64", 481 | "in": "path" 482 | } 483 | ], 484 | "tags": [ 485 | "store" 486 | ] 487 | }, 488 | "get": { 489 | "operationId": "getOrderById", 490 | "summary": "Find purchase order by ID", 491 | "responses": { 492 | "400": { 493 | "description": "Invalid ID supplied" 494 | }, 495 | "200": { 496 | "description": "successful operation", 497 | "schema": { 498 | "$ref": "#/definitions/Order" 499 | } 500 | }, 501 | "404": { 502 | "description": "Order not found" 503 | } 504 | }, 505 | "description": "For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions", 506 | "produces": [ 507 | "application/xml", 508 | "application/json" 509 | ], 510 | "parameters": [ 511 | { 512 | "type": "integer", 513 | "required": true, 514 | "maximum": 10.0, 515 | "description": "ID of pet that needs to be fetched", 516 | "name": "orderId", 517 | "format": "int64", 518 | "minimum": 1.0, 519 | "in": "path" 520 | } 521 | ], 522 | "tags": [ 523 | "store" 524 | ] 525 | } 526 | }, 527 | "/user/login": { 528 | "get": { 529 | "operationId": "loginUser", 530 | "summary": "Logs user into the system", 531 | "responses": { 532 | "400": { 533 | "description": "Invalid username/password supplied" 534 | }, 535 | "200": { 536 | "description": "successful operation", 537 | "headers": { 538 | "X-Rate-Limit": { 539 | "type": "integer", 540 | "description": "calls per hour allowed by the user", 541 | "format": "int32" 542 | }, 543 | "X-Expires-After": { 544 | "type": "string", 545 | "description": "date in UTC when token expires", 546 | "format": "date-time" 547 | } 548 | }, 549 | "schema": { 550 | "type": "string" 551 | } 552 | } 553 | }, 554 | "description": "", 555 | "produces": [ 556 | "application/xml", 557 | "application/json" 558 | ], 559 | "parameters": [ 560 | { 561 | "description": "The user name for login", 562 | "name": "username", 563 | "required": true, 564 | "in": "query", 565 | "type": "string" 566 | }, 567 | { 568 | "description": "The password for login in clear text", 569 | "name": "password", 570 | "required": true, 571 | "in": "query", 572 | "type": "string" 573 | } 574 | ], 575 | "tags": [ 576 | "user" 577 | ] 578 | } 579 | }, 580 | "/store/inventory": { 581 | "get": { 582 | "operationId": "getInventory", 583 | "security": [ 584 | { 585 | "api_key": [] 586 | } 587 | ], 588 | "summary": "Returns pet inventories by status", 589 | "responses": { 590 | "200": { 591 | "description": "successful operation", 592 | "schema": { 593 | "type": "object", 594 | "additionalProperties": { 595 | "type": "integer", 596 | "format": "int32" 597 | } 598 | } 599 | } 600 | }, 601 | "description": "Returns a map of status codes to quantities", 602 | "produces": [ 603 | "application/json" 604 | ], 605 | "parameters": [], 606 | "tags": [ 607 | "store" 608 | ] 609 | } 610 | }, 611 | "/user/logout": { 612 | "get": { 613 | "operationId": "logoutUser", 614 | "summary": "Logs out current logged in user session", 615 | "responses": { 616 | "default": { 617 | "description": "successful operation" 618 | } 619 | }, 620 | "description": "", 621 | "produces": [ 622 | "application/xml", 623 | "application/json" 624 | ], 625 | "parameters": [], 626 | "tags": [ 627 | "user" 628 | ] 629 | } 630 | }, 631 | "/user/createWithArray": { 632 | "post": { 633 | "operationId": "createUsersWithArrayInput", 634 | "summary": "Creates list of users with given input array", 635 | "responses": { 636 | "default": { 637 | "description": "successful operation" 638 | } 639 | }, 640 | "description": "", 641 | "produces": [ 642 | "application/xml", 643 | "application/json" 644 | ], 645 | "parameters": [ 646 | { 647 | "description": "List of user object", 648 | "name": "body", 649 | "required": true, 650 | "in": "body", 651 | "schema": { 652 | "type": "array", 653 | "items": { 654 | "$ref": "#/definitions/User" 655 | } 656 | } 657 | } 658 | ], 659 | "tags": [ 660 | "user" 661 | ] 662 | } 663 | }, 664 | "/pet/{petId}": { 665 | "delete": { 666 | "operationId": "deletePet", 667 | "security": [ 668 | { 669 | "petstore_auth": [ 670 | "write:pets", 671 | "read:pets" 672 | ] 673 | } 674 | ], 675 | "summary": "Deletes a pet", 676 | "responses": { 677 | "400": { 678 | "description": "Invalid ID supplied" 679 | }, 680 | "404": { 681 | "description": "Pet not found" 682 | } 683 | }, 684 | "description": "", 685 | "produces": [ 686 | "application/xml", 687 | "application/json" 688 | ], 689 | "parameters": [ 690 | { 691 | "type": "string", 692 | "name": "api_key", 693 | "required": false, 694 | "in": "header" 695 | }, 696 | { 697 | "type": "integer", 698 | "required": true, 699 | "description": "Pet id to delete", 700 | "name": "petId", 701 | "format": "int64", 702 | "in": "path" 703 | } 704 | ], 705 | "tags": [ 706 | "pet" 707 | ] 708 | }, 709 | "post": { 710 | "operationId": "updatePetWithForm", 711 | "consumes": [ 712 | "application/x-www-form-urlencoded" 713 | ], 714 | "summary": "Updates a pet in the store with form data", 715 | "security": [ 716 | { 717 | "petstore_auth": [ 718 | "write:pets", 719 | "read:pets" 720 | ] 721 | } 722 | ], 723 | "responses": { 724 | "405": { 725 | "description": "Invalid input" 726 | } 727 | }, 728 | "description": "", 729 | "produces": [ 730 | "application/xml", 731 | "application/json" 732 | ], 733 | "parameters": [ 734 | { 735 | "type": "integer", 736 | "required": true, 737 | "description": "ID of pet that needs to be updated", 738 | "name": "petId", 739 | "format": "int64", 740 | "in": "path" 741 | }, 742 | { 743 | "description": "Updated name of the pet", 744 | "name": "name", 745 | "required": false, 746 | "in": "formData", 747 | "type": "string" 748 | }, 749 | { 750 | "description": "Updated status of the pet", 751 | "name": "status", 752 | "required": false, 753 | "in": "formData", 754 | "type": "string" 755 | } 756 | ], 757 | "tags": [ 758 | "pet" 759 | ] 760 | }, 761 | "get": { 762 | "operationId": "getPetById", 763 | "security": [ 764 | { 765 | "api_key": [] 766 | } 767 | ], 768 | "summary": "Find pet by ID", 769 | "responses": { 770 | "400": { 771 | "description": "Invalid ID supplied" 772 | }, 773 | "200": { 774 | "description": "successful operation", 775 | "schema": { 776 | "$ref": "#/definitions/Pet" 777 | } 778 | }, 779 | "404": { 780 | "description": "Pet not found" 781 | } 782 | }, 783 | "description": "Returns a single pet", 784 | "produces": [ 785 | "application/xml", 786 | "application/json" 787 | ], 788 | "parameters": [ 789 | { 790 | "type": "integer", 791 | "required": true, 792 | "description": "ID of pet to return", 793 | "name": "petId", 794 | "format": "int64", 795 | "in": "path" 796 | } 797 | ], 798 | "tags": [ 799 | "pet" 800 | ] 801 | } 802 | } 803 | }, 804 | "host": "petstore.swagger.io", 805 | "definitions": { 806 | "Tag": { 807 | "type": "object", 808 | "xml": { 809 | "name": "Tag" 810 | }, 811 | "properties": { 812 | "name": { 813 | "type": "string" 814 | }, 815 | "id": { 816 | "type": "integer", 817 | "format": "int64" 818 | } 819 | } 820 | }, 821 | "Order": { 822 | "type": "object", 823 | "xml": { 824 | "name": "Order" 825 | }, 826 | "properties": { 827 | "quantity": { 828 | "type": "integer", 829 | "format": "int32" 830 | }, 831 | "petId": { 832 | "type": "integer", 833 | "format": "int64" 834 | }, 835 | "status": { 836 | "type": "string", 837 | "description": "Order Status", 838 | "enum": [ 839 | "placed", 840 | "approved", 841 | "delivered" 842 | ] 843 | }, 844 | "id": { 845 | "type": "integer", 846 | "format": "int64" 847 | }, 848 | "complete": { 849 | "type": "boolean", 850 | "default": false 851 | }, 852 | "shipDate": { 853 | "type": "string", 854 | "format": "date-time" 855 | } 856 | } 857 | }, 858 | "Pet": { 859 | "type": "object", 860 | "required": [ 861 | "name", 862 | "photoUrls" 863 | ], 864 | "xml": { 865 | "name": "Pet" 866 | }, 867 | "properties": { 868 | "category": { 869 | "$ref": "#/definitions/Category" 870 | }, 871 | "name": { 872 | "type": "string", 873 | "example": "doggie" 874 | }, 875 | "status": { 876 | "type": "string", 877 | "description": "pet status in the store", 878 | "enum": [ 879 | "available", 880 | "pending", 881 | "sold" 882 | ] 883 | }, 884 | "id": { 885 | "type": "integer", 886 | "format": "int64" 887 | }, 888 | "photoUrls": { 889 | "type": "array", 890 | "items": { 891 | "type": "string" 892 | }, 893 | "xml": { 894 | "name": "photoUrl", 895 | "wrapped": true 896 | } 897 | }, 898 | "tags": { 899 | "type": "array", 900 | "items": { 901 | "$ref": "#/definitions/Tag" 902 | }, 903 | "xml": { 904 | "name": "tag", 905 | "wrapped": true 906 | } 907 | } 908 | } 909 | }, 910 | "ApiResponse": { 911 | "type": "object", 912 | "properties": { 913 | "type": { 914 | "type": "string" 915 | }, 916 | "code": { 917 | "type": "integer", 918 | "format": "int32" 919 | }, 920 | "message": { 921 | "type": "string" 922 | } 923 | } 924 | }, 925 | "Category": { 926 | "type": "object", 927 | "xml": { 928 | "name": "Category" 929 | }, 930 | "properties": { 931 | "name": { 932 | "type": "string" 933 | }, 934 | "id": { 935 | "type": "integer", 936 | "format": "int64" 937 | } 938 | } 939 | }, 940 | "User": { 941 | "type": "object", 942 | "xml": { 943 | "name": "User" 944 | }, 945 | "properties": { 946 | "userStatus": { 947 | "type": "integer", 948 | "description": "User Status", 949 | "format": "int32" 950 | }, 951 | "phone": { 952 | "type": "string" 953 | }, 954 | "password": { 955 | "type": "string" 956 | }, 957 | "firstName": { 958 | "type": "string" 959 | }, 960 | "id": { 961 | "type": "integer", 962 | "format": "int64" 963 | }, 964 | "username": { 965 | "type": "string" 966 | }, 967 | "email": { 968 | "type": "string" 969 | }, 970 | "lastName": { 971 | "type": "string" 972 | } 973 | } 974 | } 975 | }, 976 | "externalDocs": { 977 | "description": "Find out more about Swagger", 978 | "url": "http://swagger.io" 979 | }, 980 | "basePath": "/v2", 981 | "info": { 982 | "title": "Swagger Petstore", 983 | "contact": { 984 | "email": "apiteam@swagger.io" 985 | }, 986 | "license": { 987 | "name": "Apache 2.0", 988 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 989 | }, 990 | "description": "This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.", 991 | "termsOfService": "http://swagger.io/terms/", 992 | "version": "1.0.0" 993 | }, 994 | "schemes": [ 995 | "http" 996 | ], 997 | "securityDefinitions": { 998 | "petstore_auth": { 999 | "type": "oauth2", 1000 | "authorizationUrl": "http://petstore.swagger.io/oauth/dialog", 1001 | "scopes": { 1002 | "read:pets": "read your pets", 1003 | "write:pets": "modify pets in your account" 1004 | }, 1005 | "flow": "implicit" 1006 | }, 1007 | "api_key": { 1008 | "type": "apiKey", 1009 | "name": "api_key", 1010 | "in": "header" 1011 | } 1012 | }, 1013 | "tags": [ 1014 | { 1015 | "description": "Everything about your Pets", 1016 | "name": "pet", 1017 | "externalDocs": { 1018 | "description": "Find out more", 1019 | "url": "http://swagger.io" 1020 | } 1021 | }, 1022 | { 1023 | "description": "Access to Petstore orders", 1024 | "name": "store" 1025 | }, 1026 | { 1027 | "description": "Operations about user", 1028 | "name": "user", 1029 | "externalDocs": { 1030 | "description": "Find out more about our store", 1031 | "url": "http://swagger.io" 1032 | } 1033 | } 1034 | ] 1035 | } 1036 | -------------------------------------------------------------------------------- /tests/test_decode.py: -------------------------------------------------------------------------------- 1 | from coreapi import Document 2 | from openapi_codec import OpenAPICodec 3 | import os 4 | 5 | 6 | test_filepath = os.path.join(os.path.dirname(__file__), 'petstore.json') 7 | 8 | 9 | def test_decode(): 10 | test_content = open(test_filepath, 'rb').read() 11 | codec = OpenAPICodec() 12 | document = (codec.load(test_content)) 13 | assert isinstance(document, Document) 14 | assert set(document.keys()) == set(['pet', 'store', 'user']) 15 | assert document.title == 'Swagger Petstore' 16 | -------------------------------------------------------------------------------- /tests/test_encode.py: -------------------------------------------------------------------------------- 1 | import coreapi 2 | import coreschema 3 | from openapi_codec.encode import generate_swagger_object, _get_parameters 4 | from unittest import TestCase 5 | 6 | 7 | class TestBasicInfo(TestCase): 8 | def setUp(self): 9 | self.document = coreapi.Document( 10 | title='Example API', 11 | url='https://www.example.com/', 12 | description='Example description.', 13 | ) 14 | self.swagger = generate_swagger_object(self.document) 15 | 16 | def test_info(self): 17 | self.assertIn('info', self.swagger) 18 | expected = { 19 | 'title': self.document.title, 20 | 'version': '', 21 | 'description': self.document.description, 22 | } 23 | self.assertEquals(self.swagger['info'], expected) 24 | 25 | def test_swagger_version(self): 26 | self.assertIn('swagger', self.swagger) 27 | expected = '2.0' 28 | self.assertEquals(self.swagger['swagger'], expected) 29 | 30 | def test_host(self): 31 | self.assertIn('host', self.swagger) 32 | expected = 'www.example.com' 33 | self.assertEquals(self.swagger['host'], expected) 34 | 35 | def test_schemes(self): 36 | self.assertIn('schemes', self.swagger) 37 | expected = ['https'] 38 | self.assertEquals(self.swagger['schemes'], expected) 39 | 40 | 41 | class TestPaths(TestCase): 42 | def setUp(self): 43 | self.path = '/users/' 44 | self.document = coreapi.Document( 45 | content={ 46 | 'users': { 47 | 'create': coreapi.Link( 48 | action='post', 49 | url=self.path 50 | ), 51 | 'list': coreapi.Link( 52 | action='get', 53 | url=self.path 54 | ) 55 | } 56 | } 57 | ) 58 | self.swagger = generate_swagger_object(self.document) 59 | 60 | def test_paths(self): 61 | self.assertIn('paths', self.swagger) 62 | self.assertIn(self.path, self.swagger['paths']) 63 | self.assertIn('get', self.swagger['paths'][self.path]) 64 | self.assertIn('post', self.swagger['paths'][self.path]) 65 | expected = { 66 | 'responses': { 67 | '200': { 68 | 'description': '' 69 | } 70 | }, 71 | 'parameters': [], 72 | 'operationId': 'list', 73 | 'tags': ['users'] 74 | } 75 | self.assertEquals(self.swagger['paths'][self.path]['get'], expected) 76 | expected = { 77 | 'responses': { 78 | '201': { 79 | 'description': '' 80 | } 81 | }, 82 | 'parameters': [], 83 | 'operationId': 'create', 84 | 'tags': ['users'] 85 | } 86 | self.assertEquals(self.swagger['paths'][self.path]['post'], expected) 87 | 88 | 89 | class TestParameters(TestCase): 90 | def setUp(self): 91 | self.field = coreapi.Field( 92 | name='email', 93 | required=True, 94 | location='query', 95 | schema=coreschema.String(description='A valid email address.') 96 | ) 97 | self.swagger = _get_parameters(coreapi.Link(fields=[self.field]), encoding='') 98 | 99 | def test_expected_fields(self): 100 | self.assertEquals(len(self.swagger), 1) 101 | expected = { 102 | 'name': self.field.name, 103 | 'required': self.field.required, 104 | 'in': 'query', 105 | 'description': self.field.schema.description, 106 | 'type': 'string' # Everything is a string for now. 107 | } 108 | self.assertEquals(self.swagger[0], expected) 109 | -------------------------------------------------------------------------------- /tests/test_mappings.py: -------------------------------------------------------------------------------- 1 | from openapi_codec import OpenAPICodec 2 | import coreapi 3 | import coreschema 4 | 5 | 6 | codec = OpenAPICodec() 7 | doc = coreapi.Document( 8 | url='https://api.example.com/', 9 | title='Example API', 10 | content={ 11 | 'simple_link': coreapi.Link('/simple_link/', description='example link'), 12 | 'location': { 13 | 'query': coreapi.Link('/location/query/', fields=[ 14 | coreapi.Field(name='a', required=True, schema=coreschema.String(description='example field')), 15 | coreapi.Field(name='b') 16 | ]), 17 | 'form': coreapi.Link('/location/form/', action='post', fields=[ 18 | coreapi.Field(name='a', required=True, schema=coreschema.String(description='example field')), 19 | coreapi.Field(name='b'), 20 | ]), 21 | 'body': coreapi.Link('/location/body/', action='post', fields=[ 22 | coreapi.Field(name='example', location='body') 23 | ]), 24 | 'path': coreapi.Link('/location/path/{id}/', fields=[ 25 | coreapi.Field(name='id', location='path', required=True) 26 | ]) 27 | }, 28 | 'encoding': { 29 | 'multipart': coreapi.Link('/encoding/multipart/', action='post', encoding='multipart/form-data', fields=[ 30 | coreapi.Field(name='a', required=True), 31 | coreapi.Field(name='b') 32 | ]), 33 | 'multipart-body': coreapi.Link('/encoding/multipart-body/', action='post', encoding='multipart/form-data', fields=[ 34 | coreapi.Field(name='example', location='body') 35 | ]), 36 | 'urlencoded': coreapi.Link('/encoding/urlencoded/', action='post', encoding='application/x-www-form-urlencoded', fields=[ 37 | coreapi.Field(name='a', required=True), 38 | coreapi.Field(name='b') 39 | ]), 40 | 'urlencoded-body': coreapi.Link('/encoding/urlencoded-body/', action='post', encoding='application/x-www-form-urlencoded', fields=[ 41 | coreapi.Field(name='example', location='body') 42 | ]), 43 | 'upload': coreapi.Link('/encoding/upload/', action='post', encoding='application/octet-stream', fields=[ 44 | coreapi.Field(name='example', location='body', required=True) 45 | ]), 46 | } 47 | } 48 | ) 49 | 50 | 51 | def test_mapping(): 52 | """ 53 | Ensure that a document that is encoded into OpenAPI and then decoded 54 | comes back as expected. 55 | """ 56 | content = codec.dump(doc) 57 | new = codec.load(content) 58 | assert new.title == 'Example API' 59 | 60 | assert new['simple_link'] == coreapi.Link( 61 | url='https://api.example.com/simple_link/', 62 | action='get', 63 | description='example link' 64 | ) 65 | 66 | assert new['location']['query'] == coreapi.Link( 67 | url='https://api.example.com/location/query/', 68 | action='get', 69 | fields=[ 70 | coreapi.Field( 71 | name='a', 72 | location='query', 73 | schema=coreschema.String(description='example field'), 74 | required=True 75 | ), 76 | coreapi.Field( 77 | name='b', 78 | location='query', 79 | schema=coreschema.String() 80 | ) 81 | ] 82 | ) 83 | 84 | assert new['location']['path'] == coreapi.Link( 85 | url='https://api.example.com/location/path/{id}/', 86 | action='get', 87 | fields=[ 88 | coreapi.Field( 89 | name='id', 90 | location='path', 91 | required=True, 92 | schema=coreschema.String() 93 | ) 94 | ] 95 | ) 96 | 97 | assert new['location']['form'] == coreapi.Link( 98 | url='https://api.example.com/location/form/', 99 | action='post', 100 | encoding='application/json', 101 | fields=[ 102 | coreapi.Field( 103 | name='a', 104 | location='form', 105 | required=True, 106 | schema=coreschema.String(description='example field') 107 | ), 108 | coreapi.Field( 109 | name='b', 110 | location='form', 111 | schema=coreschema.String() 112 | ) 113 | ] 114 | ) 115 | 116 | assert new['location']['body'] == coreapi.Link( 117 | url='https://api.example.com/location/body/', 118 | action='post', 119 | encoding='application/json', 120 | fields=[ 121 | coreapi.Field( 122 | name='example', 123 | location='body', 124 | schema=coreschema.String() 125 | ) 126 | 127 | ] 128 | ) 129 | 130 | assert new['encoding']['multipart'] == coreapi.Link( 131 | url='https://api.example.com/encoding/multipart/', 132 | action='post', 133 | encoding='multipart/form-data', 134 | fields=[ 135 | coreapi.Field( 136 | name='a', 137 | location='form', 138 | required=True, 139 | schema=coreschema.String() 140 | ), 141 | coreapi.Field( 142 | name='b', 143 | location='form', 144 | schema=coreschema.String() 145 | ) 146 | ] 147 | ) 148 | 149 | assert new['encoding']['urlencoded'] == coreapi.Link( 150 | url='https://api.example.com/encoding/urlencoded/', 151 | action='post', 152 | encoding='application/x-www-form-urlencoded', 153 | fields=[ 154 | coreapi.Field( 155 | name='a', 156 | location='form', 157 | required=True, 158 | schema=coreschema.String() 159 | ), 160 | coreapi.Field( 161 | name='b', 162 | location='form', 163 | schema=coreschema.String() 164 | ) 165 | ] 166 | ) 167 | 168 | assert new['encoding']['upload'] == coreapi.Link( 169 | url='https://api.example.com/encoding/upload/', 170 | action='post', 171 | encoding='application/octet-stream', 172 | fields=[ 173 | coreapi.Field( 174 | name='example', 175 | location='body', 176 | required=True, 177 | schema=coreschema.String() 178 | ) 179 | ] 180 | ) 181 | 182 | # Swagger doesn't really support form data in the body, but we 183 | # map it onto something reasonable anyway. 184 | assert new['encoding']['multipart-body'] == coreapi.Link( 185 | url='https://api.example.com/encoding/multipart-body/', 186 | action='post', 187 | encoding='multipart/form-data', 188 | fields=[ 189 | coreapi.Field( 190 | name='example', 191 | location='body', 192 | schema=coreschema.String() 193 | ) 194 | ] 195 | ) 196 | 197 | assert new['encoding']['urlencoded-body'] == coreapi.Link( 198 | url='https://api.example.com/encoding/urlencoded-body/', 199 | action='post', 200 | encoding='application/x-www-form-urlencoded', 201 | fields=[ 202 | coreapi.Field( 203 | name='example', 204 | location='body', 205 | 206 | schema=coreschema.String() 207 | ) 208 | ] 209 | ) 210 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35,py34,py27 3 | [testenv] 4 | deps = -rrequirements.txt 5 | commands = ./runtests 6 | [testenv:py27] 7 | deps = 8 | -rrequirements.txt 9 | mock 10 | commands = ./runtests 11 | --------------------------------------------------------------------------------