From 6ff9840bdecbd877732d678e08c84bfaf288a3f8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 4 Jul 2016 16:38:17 +0100 Subject: [PATCH] Schemas & client libraries. (#4179) * Added schema generation support. * New tutorial section. * API guide on schema generation. * Topic guide on API clients. --- docs/api-guide/schemas.md | 383 ++++++++++++++++++ docs/img/corejson-format.png | Bin 0 -> 20499 bytes docs/index.md | 7 + docs/topics/api-clients.md | 294 ++++++++++++++ docs/tutorial/6-viewsets-and-routers.md | 26 +- .../7-schemas-and-client-libraries.md | 216 ++++++++++ mkdocs.yml | 3 + requirements/requirements-optionals.txt | 1 + rest_framework/compat.py | 10 + rest_framework/filters.py | 20 + rest_framework/pagination.py | 14 + rest_framework/renderers.py | 17 +- rest_framework/routers.py | 43 +- rest_framework/schemas.py | 300 ++++++++++++++ rest_framework/utils/encoders.py | 7 +- rest_framework/viewsets.py | 1 + runtests.py | 2 +- schema-support | 0 tests/test_routers.py | 2 +- tests/test_schemas.py | 137 +++++++ 20 files changed, 1449 insertions(+), 34 deletions(-) create mode 100644 docs/api-guide/schemas.md create mode 100644 docs/img/corejson-format.png create mode 100644 docs/topics/api-clients.md create mode 100644 docs/tutorial/7-schemas-and-client-libraries.md create mode 100644 rest_framework/schemas.py create mode 100644 schema-support create mode 100644 tests/test_schemas.py diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md new file mode 100644 index 000000000..9fa1ba2e3 --- /dev/null +++ b/docs/api-guide/schemas.md @@ -0,0 +1,383 @@ +source: schemas.py + +# Schemas + +> A machine-readable [schema] describes what resources are available via the API, what their URLs are, how they are represented and what operations they support. +> +> — Heroku, [JSON Schema for the Heroku Platform API][cite] + +API schemas are a useful tool that allow for a range of use cases, including +generating reference documentation, or driving dynamic client libraries that +can interact with your API. + +## Representing schemas internally + +REST framework uses [Core API][coreapi] in order to model schema information in +a format-independent representation. This information can then be rendered +into various different schema formats, or used to generate API documentation. + +When using Core API, a schema is represented as a `Document` which is the +top-level container object for information about the API. Available API +interactions are represented using `Link` objects. Each link includes a URL, +HTTP method, and may include a list of `Field` instances, which describe any +parameters that may be accepted by the API endpoint. The `Link` and `Field` +instances may also include descriptions, that allow an API schema to be +rendered into user documentation. + +Here's an example of an API description that includes a single `search` +endpoint: + + coreapi.Document( + title='Flight Search API', + url='https://api.example.org/', + content={ + 'search': coreapi.Link( + url='/search/', + action='get', + fields=[ + coreapi.Field( + name='from', + required=True, + location='query', + description='City name or airport code.' + ), + coreapi.Field( + name='to', + required=True, + location='query', + description='City name or airport code.' + ), + coreapi.Field( + name='date', + required=True, + location='query', + description='Flight date in "YYYY-MM-DD" format.' + ) + ], + description='Return flight availability and prices.' + ) + } + ) + +## Schema output formats + +In order to be presented in an HTTP response, the internal representation +has to be rendered into the actual bytes that are used in the response. + +[Core JSON][corejson] is designed as a canonical format for use with Core API. +REST framework includes a renderer class for handling this media type, which +is available as `renderers.CoreJSONRenderer`. + +Other schema formats such as [Open API][open-api] (Formerly "Swagger"), +[JSON HyperSchema][json-hyperschema], or [API Blueprint][api-blueprint] can +also be supported by implementing a custom renderer class. + +## Schemas vs Hypermedia + +It's worth pointing out here that Core API can also be used to model hypermedia +responses, which present an alternative interaction style to API schemas. + +With an API schema, the entire available interface is presented up-front +as a single endpoint. Responses to individual API endpoints are then typically +presented as plain data, without any further interactions contained in each +response. + +With Hypermedia, the client is instead presented with a document containing +both data and available interactions. Each interaction results in a new +document, detailing both the current state and the available interactions. + +Further information and support on building Hypermedia APIs with REST framework +is planned for a future version. + +--- + +# Adding a schema + +You'll need to install the `coreapi` package in order to add schema support +for REST framework. + + pip install coreapi + +REST framework includes functionality for auto-generating a schema, +or allows you to specify one explicitly. There are a few different ways to +add a schema to your API, depending on exactly what you need. + +## Using DefaultRouter + +If you're using `DefaultRouter` then you can include an auto-generated schema, +simply by adding a `schema_title` argument to the router. + + router = DefaultRouter(schema_title='Server Monitoring API') + +The schema will be included at the root URL, `/`, and presented to clients +that include the Core JSON media type in their `Accept` header. + + $ http http://127.0.0.1:8000/ Accept:application/vnd.coreapi+json + HTTP/1.0 200 OK + Allow: GET, HEAD, OPTIONS + Content-Type: application/vnd.coreapi+json + + { + "_meta": { + "title": "Server Monitoring API" + }, + "_type": "document", + ... + } + +This is a great zero-configuration option for when you want to get up and +running really quickly. If you want a little more flexibility over the +schema output then you'll need to consider using `SchemaGenerator` instead. + +## Using SchemaGenerator + +The most common way to add a schema to your API is to use the `SchemaGenerator` +class to auto-generate the `Document` instance, and to return that from a view. + +This option gives you the flexibility of setting up the schema endpoint +with whatever behaviour you want. For example, you can apply different +permission, throttling or authentication policies to the schema endpoint. + +Here's an example of using `SchemaGenerator` together with a view to +return the schema. + +**views.py:** + + from rest_framework.decorators import api_view, renderer_classes + from rest_framework import renderers, schemas + + generator = schemas.SchemaGenerator(title='Bookings API') + + @api_view() + @renderer_classes([renderers.CoreJSONRenderer]) + def schema_view(request): + return generator.get_schema() + +**urls.py:** + + urlpatterns = [ + url('/', schema_view), + ... + ] + +You can also serve different schemas to different users, depending on the +permissions they have available. This approach can be used to ensure that +unauthenticated requests are presented with a different schema to +authenticated requests, or to ensure that different parts of the API are +made visible to different users depending on their role. + +In order to present a schema with endpoints filtered by user permissions, +you need to pass the `request` argument to the `get_schema()` method, like so: + + @api_view() + @renderer_classes([renderers.CoreJSONRenderer]) + def schema_view(request): + return generator.get_schema(request=request) + +## Explicit schema definition + +An alternative to the auto-generated approach is to specify the API schema +explicitly, by declaring a `Document` object in your codebase. Doing so is a +little more work, but ensures that you have full control over the schema +representation. + + import coreapi + from rest_framework.decorators import api_view, renderer_classes + from rest_framework import renderers + + schema = coreapi.Document( + title='Bookings API', + content={ + ... + } + ) + + @api_view() + @renderer_classes([renderers.CoreJSONRenderer]) + def schema_view(request): + return schema + +## Static schema file + +A final option is to write your API schema as a static file, using one +of the available formats, such as Core JSON or Open API. + +You could then either: + +* Write a schema definition as a static file, and [serve the static file directly][static-files]. +* Write a schema definition that is loaded using `Core API`, and then + rendered to one of many available formats, depending on the client request. + +--- + +# API Reference + +## SchemaGenerator + +A class that deals with introspecting your API views, which can be used to +generate a schema. + +Typically you'll instantiate `SchemaGenerator` with a single argument, like so: + + generator = SchemaGenerator(title='Stock Prices API') + +Arguments: + +* `title` - The name of the API. **required** +* `patterns` - A list of URLs to inspect when generating the schema. Defaults to the project's URL conf. +* `urlconf` - A URL conf module name to use when generating the schema. Defaults to `settings.ROOT_URLCONF`. + +### get_schema() + +Returns a `coreapi.Document` instance that represents the API schema. + + @api_view + @renderer_classes([renderers.CoreJSONRenderer]) + def schema_view(request): + return generator.get_schema() + +Arguments: + +* `request` - The incoming request. Optionally used if you want to apply per-user permissions to the schema-generation. + +--- + +## Core API + +This documentation gives a brief overview of the components within the `coreapi` +package that are used to represent an API schema. + +Note that these classes are imported from the `coreapi` package, rather than +from the `rest_framework` package. + +### Document + +Represents a container for the API schema. + +#### `title` + +A name for the API. + +#### `url` + +A canonical URL for the API. + +#### `content` + +A dictionary, containing the `Link` objects that the schema contains. + +In order to provide more structure to the schema, the `content` dictionary +may be nested, typically to a second level. For example: + + content={ + "bookings": { + "list": Link(...), + "create": Link(...), + ... + }, + "venues": { + "list": Link(...), + ... + }, + ... + } + +### Link + +Represents an individual API endpoint. + +#### `url` + +The URL of the endpoint. May be a URI template, such as `/users/{username}/`. + +#### `action` + +The HTTP method associated with the endpoint. Note that URLs that support +more than one HTTP method, should correspond to a single `Link` for each. + +#### `fields` + +A list of `Field` instances, describing the available parameters on the input. + +#### `description` + +A short description of the meaning and intended usage of the endpoint. + +### Field + +Represents a single input parameter on a given API endpoint. + +#### `name` + +A descriptive name for the input. + +#### `required` + +A boolean, indicated if the client is required to included a value, or if +the parameter can be omitted. + +#### `location` + +Determines how the information is encoded into the request. Should be one of +the following strings: + +**"path"** + +Included in a templated URI. For example a `url` value of `/products/{product_code}/` could be used together with a `"path"` field, to handle API inputs in a URL path such as `/products/slim-fit-jeans/`. + +These fields will normally correspond with [named arguments in the project URL conf][named-arguments]. + +**"query"** + +Included as a URL query parameter. For example `?search=sale`. Typically for `GET` requests. + +These fields will normally correspond with pagination and filtering controls on a view. + +**"form"** + +Included in the request body, as a single item of a JSON object or HTML form. For example `{"colour": "blue", ...}`. Typically for `POST`, `PUT` and `PATCH` requests. Multiple `"form"` fields may be included on a single link. + +These fields will normally correspond with serializer fields on a view. + +**"body"** + +Included as the complete request body. Typically for `POST`, `PUT` and `PATCH` requests. No more than one `"body"` field may exist on a link. May not be used together with `"form"` fields. + +These fields will normally correspond with views that use `ListSerializer` to validate the request input, or with file upload views. + +#### `encoding` + +**"application/json"** + +JSON encoded request content. Corresponds to views using `JSONParser`. +Valid only if either one or more `location="form"` fields, or a single +`location="body"` field is included on the `Link`. + +**"multipart/form-data"** + +Multipart encoded request content. Corresponds to views using `MultiPartParser`. +Valid only if one or more `location="form"` fields is included on the `Link`. + +**"application/x-www-form-urlencoded"** + +URL encoded request content. Corresponds to views using `FormParser`. Valid +only if one or more `location="form"` fields is included on the `Link`. + +**"application/octet-stream"** + +Binary upload request content. Corresponds to views using `FileUploadParser`. +Valid only if a `location="body"` field is included on the `Link`. + +#### `description` + +A short description of the meaning and intended usage of the input field. + + +[cite]: https://blog.heroku.com/archives/2014/1/8/json_schema_for_heroku_platform_api +[coreapi]: http://www.coreapi.org/ +[corejson]: http://www.coreapi.org/specification/encoding/#core-json-encoding +[open-api]: https://openapis.org/ +[json-hyperschema]: http://json-schema.org/latest/json-schema-hypermedia.html +[api-blueprint]: https://apiblueprint.org/ +[static-files]: https://docs.djangoproject.com/en/dev/howto/static-files/ +[named-arguments]: https://docs.djangoproject.com/en/dev/topics/http/urls/#named-groups diff --git a/docs/img/corejson-format.png b/docs/img/corejson-format.png new file mode 100644 index 0000000000000000000000000000000000000000..36c197a0d02a78fa165f3cd123343ca87a27f102 GIT binary patch literal 20499 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 W6-VWcKNo4ud8WozM*kXmru+}qf?_iO literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index cc8d183f4..072d80c66 100644 --- a/docs/index.md +++ b/docs/index.md @@ -91,6 +91,7 @@ REST framework requires the following: The following packages are optional: +* [coreapi][coreapi] (1.21.0+) - Schema generation support. * [Markdown][markdown] (2.1.0+) - Markdown support for the browsable API. * [django-filter][django-filter] (0.9.2+) - Filtering support. * [django-crispy-forms][django-crispy-forms] - Improved HTML display for filtering. @@ -214,6 +215,7 @@ The API guide is your complete reference manual to all the functionality provide * [Versioning][versioning] * [Content negotiation][contentnegotiation] * [Metadata][metadata] +* [Schemas][schemas] * [Format suffixes][formatsuffixes] * [Returning URLs][reverse] * [Exceptions][exceptions] @@ -226,6 +228,7 @@ The API guide is your complete reference manual to all the functionality provide General guides to using REST framework. * [Documenting your API][documenting-your-api] +* [API Clients][api-clients] * [Internationalization][internationalization] * [AJAX, CSRF & CORS][ajax-csrf-cors] * [HTML & Forms][html-and-forms] @@ -296,6 +299,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [redhat]: https://www.redhat.com/ [heroku]: https://www.heroku.com/ [eventbrite]: https://www.eventbrite.co.uk/about/ +[coreapi]: http://pypi.python.org/pypi/coreapi/ [markdown]: http://pypi.python.org/pypi/Markdown/ [django-filter]: http://pypi.python.org/pypi/django-filter [django-crispy-forms]: https://github.com/maraujop/django-crispy-forms @@ -318,6 +322,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [tut-4]: tutorial/4-authentication-and-permissions.md [tut-5]: tutorial/5-relationships-and-hyperlinked-apis.md [tut-6]: tutorial/6-viewsets-and-routers.md +[tut-7]: tutorial/7-schemas-and-client-libraries.md [request]: api-guide/requests.md [response]: api-guide/responses.md @@ -339,6 +344,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [versioning]: api-guide/versioning.md [contentnegotiation]: api-guide/content-negotiation.md [metadata]: api-guide/metadata.md +[schemas]: 'api-guide/schemas.md' [formatsuffixes]: api-guide/format-suffixes.md [reverse]: api-guide/reverse.md [exceptions]: api-guide/exceptions.md @@ -347,6 +353,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [settings]: api-guide/settings.md [documenting-your-api]: topics/documenting-your-api.md +[api-clients]: topics/api-clients.md [internationalization]: topics/internationalization.md [ajax-csrf-cors]: topics/ajax-csrf-cors.md [html-and-forms]: topics/html-and-forms.md diff --git a/docs/topics/api-clients.md b/docs/topics/api-clients.md new file mode 100644 index 000000000..5f09c2a8f --- /dev/null +++ b/docs/topics/api-clients.md @@ -0,0 +1,294 @@ +# API Clients + +An API client handles the underlying details of how network requests are made +and how responses are decoded. They present the developer with an application +interface to work against, rather than working directly with the network interface. + +The API clients documented here are not restricted to APIs built with Django REST framework. + They can be used with any API that exposes a supported schema format. + +For example, [the Heroku platform API][heroku-api] exposes a schema in the JSON +Hyperschema format. As a result, the Core API command line client and Python +client library can be [used to interact with the Heroku API][heroku-example]. + +## Client-side Core API + +[Core API][core-api] is a document specification that can be used to describe APIs. It can +be used either server-side, as is done with REST framework's [schema generation][schema-generation], +or used client-side, as described here. + +When used client-side, Core API allows for *dynamically driven client libraries* +that can interact with any API that exposes a supported schema or hypermedia +format. + +Using a dynamically driven client has a number of advantages over interacting +with an API by building HTTP requests directly. + +#### More meaningful interaction + +API interactions are presented in a more meaningful way. You're working at +the application interface layer, rather than the network interface layer. + +#### Resilience & evolvability + +The client determines what endpoints are available, what parameters exist +against each particular endpoint, and how HTTP requests are formed. + +This also allows for a degree of API evolvability. URLs can be modified +without breaking existing clients, or more efficient encodings can be used +on-the-wire, with clients transparently upgrading. + +#### Self-descriptive APIs + +A dynamically driven client is able to present documentation on the API to the +end user. This documentation allows the user to discover the available endpoints +and parameters, and better understand the API they are working with. + +Because this documentation is driven by the API schema it will always be fully +up to date with the most recently deployed version of the service. + +--- + +# Command line client + +The command line client allows you to inspect and interact with any API that +exposes a supported schema format. + +## Getting started + +To install the Core API command line client, use `pip`. + + $ pip install coreapi + +To start inspecting and interacting with an API the schema must first be loaded +from the network. + + + $ coreapi get http://api.example.org/ + + snippets: { + create(code, [title], [linenos], [language], [style]) + destroy(pk) + highlight(pk) + list([page]) + partial_update(pk, [title], [code], [linenos], [language], [style]) + retrieve(pk) + update(pk, code, [title], [linenos], [language], [style]) + } + users: { + list([page]) + retrieve(pk) + } + +This will then load the schema, displaying the resulting `Document`. This +`Document` includes all the available interactions that may be made against the API. + +To interact with the API, use the `action` command. This command requires a list +of keys that are used to index into the link. + + $ coreapi action users list + [ + { + "url": "http://127.0.0.1:8000/users/2/", + "id": 2, + "username": "aziz", + "snippets": [] + }, + ... + ] + +To inspect the underlying HTTP request and response, use the `--debug` flag. + + $ coreapi action users list --debug + > GET /users/ HTTP/1.1 + > Accept: application/vnd.coreapi+json, */* + > Authorization: Basic bWF4Om1heA== + > Host: 127.0.0.1 + > User-Agent: coreapi + < 200 OK + < Allow: GET, HEAD, OPTIONS + < Content-Type: application/json + < Date: Thu, 30 Jun 2016 10:51:46 GMT + < Server: WSGIServer/0.1 Python/2.7.10 + < Vary: Accept, Cookie + < + < [{"url":"http://127.0.0.1/users/2/","id":2,"username":"aziz","snippets":[]},{"url":"http://127.0.0.1/users/3/","id":3,"username":"amy","snippets":["http://127.0.0.1/snippets/3/"]},{"url":"http://127.0.0.1/users/4/","id":4,"username":"max","snippets":["http://127.0.0.1/snippets/4/","http://127.0.0.1/snippets/5/","http://127.0.0.1/snippets/6/","http://127.0.0.1/snippets/7/"]},{"url":"http://127.0.0.1/users/5/","id":5,"username":"jose","snippets":[]},{"url":"http://127.0.0.1/users/6/","id":6,"username":"admin","snippets":["http://127.0.0.1/snippets/1/","http://127.0.0.1/snippets/2/"]}] + + [ + ... + ] + +Some actions may include optional or required parameters. + + $ coreapi action users create --params username example + +## Authentication & headers + +The `credentials` command is used to manage the request `Authentication:` header. +Any credentials added are always linked to a particular domain, so as to ensure +that credentials are not leaked across differing APIs. + +The format for adding a new credential is: + + coreapi credentials add + +For instance: + + coreapi credentials add api.example.org "Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b" + +The optional `--auth` flag also allows you to add specific types of authentication, +handling the encoding for you. Currently only `"basic"` is supported as an option here. +For example: + + coreapi credentials add api.example.org tomchristie:foobar --auth basic + +You can also add specific request headers, using the `headers` command: + + coreapi headers add api.example.org x-api-version 2 + +For more information and a listing of the available subcommands use `coreapi +credentials --help` or `coreapi headers --help`. + +## Utilities + +The command line client includes functionality for bookmarking API URLs +under a memorable name. For example, you can add a bookmark for the +existing API, like so... + + coreapi bookmarks add accountmanagement + +There is also functionality for navigating forward or backward through the +history of which API URLs have been accessed. + + coreapi history show + coreapi history back + +For more information and a listing of the available subcommands use +`coreapi bookmarks --help` or `coreapi history --help`. + +## Other commands + +To display the current `Document`: + + coreapi show + +To reload the current `Document` from the network: + + coreapi reload + +To load a schema file from disk: + + coreapi load my-api-schema.json --format corejson + +To remove the current document, along with all currently saved history, +credentials, headers and bookmarks: + + coreapi clear + +--- + +# Python client library + +The `coreapi` Python package allows you to programatically interact with any +API that exposes a supported schema format. + +## Getting started + +You'll need to install the `coreapi` package using `pip` before you can get +started. Once you've done so, open up a python terminal. + +In order to start working with an API, we first need a `Client` instance. The +client holds any configuration around which codecs and transports are supported +when interacting with an API, which allows you to provide for more advanced +kinds of behaviour. + + import coreapi + client = coreapi.Client() + +Once we have a `Client` instance, we can fetch an API schema from the network. + + schema = client.get('https://api.example.org/') + +The object returned from this call will be a `Document` instance, which is +the internal representation of the interface that we are interacting with. + +Now that we have our schema `Document`, we can now start to interact with the API: + + users = client.action(schema, ['users', 'list']) + +Some endpoints may include named parameters, which might be either optional or required: + + new_user = client.action(schema, ['users', 'create'], params={"username": "max"}) + +## Codecs + +Codecs are responsible for encoding or decoding Documents. + +The decoding process is used by a client to take a bytestring of an API schema +definition, and returning the Core API `Document` that represents that interface. + +A codec should be associated with a particular media type, such as **TODO**. + +This media type is used by the server in the response `Content-Type` header, +in order to indicate what kind of data is being returned in the response. + +#### Configuring codecs + +The codecs that are available can be configured when instantiating a client. +The keyword argument used here is `decoders`, because in the context of a +client the codecs are only for *decoding* responses. + +In the following example we'll configure a client to only accept `Core JSON` +and `JSON` responses. This will allow us to receive and decode a Core JSON schema, +and subsequently to receive JSON responses made against the API. + + from coreapi import codecs, Client + + decoders = [codecs.CoreJSONCodec(), codecs.JSONCodec()] + client = Client(decoders=decoders) + +#### Loading and saving schemas + +You can use a codec directly, in order to load an existing schema definition, +and return the resulting `Document`. + + schema_definition = open('my-api-schema.json', 'r').read() + codec = codecs.CoreJSONCodec() + schema = codec.load(schema_definition) + +You can also use a codec directly to generate a schema definition given a `Document` instance: + + schema_definition = codec.dump(schema) + output_file = open('my-api-schema.json', 'r') + output_file.write(schema_definition) + +## Transports + +Transports are responsible for making network requests. The set of transports +that a client has installed determines which network protocols it is able to +support. + +Currently the `coreapi` library only includes an HTTP/HTTPS transport, but +other protocols can also be supported. + +#### Configuring transports + +The behaviour of the network layer can be customized by configuring the +transports that the client is instantiated with. + + import requests + from coreapi import transports, Client + + credentials = {'api.example.org': 'Token 3bd44a009d16ff'} + transports = transports.HTTPTransport(credentials=credentials) + client = Client(transports=transports) + +More complex customizations can also be achieved, for example modifying the +underlying `requests.Session` instance to [attach transport adaptors][transport-adaptors] +that modify the outgoing requests. + +[heroku-api]: https://devcenter.heroku.com/categories/platform-api +[heroku-example]: http://www.coreapi.org/tools-and-resources/example-services/#heroku-json-hyper-schema +[core-api]: http://www.coreapi.org/ +[schema-generation]: ../api-guide/schemas.md +[transport-adaptors]: http://docs.python-requests.org/en/master/user/advanced/#transport-adapters diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md index f1dbe9443..00152cc17 100644 --- a/docs/tutorial/6-viewsets-and-routers.md +++ b/docs/tutorial/6-viewsets-and-routers.md @@ -130,27 +130,7 @@ Using viewsets can be a really useful abstraction. It helps ensure that URL con That doesn't mean it's always the right approach to take. There's a similar set of trade-offs to consider as when using class-based views instead of function based views. Using viewsets is less explicit than building your views individually. -## Reviewing our work +In [part 7][tut-7] of the tutorial we'll look at how we can add an API schema, +and interact with our API using a client library or command line tool. -With an incredibly small amount of code, we've now got a complete pastebin Web API, which is fully web browsable, and comes complete with authentication, per-object permissions, and multiple renderer formats. - -We've walked through each step of the design process, and seen how if we need to customize anything we can gradually work our way down to simply using regular Django views. - -You can review the final [tutorial code][repo] on GitHub, or try out a live example in [the sandbox][sandbox]. - -## Onwards and upwards - -We've reached the end of our tutorial. If you want to get more involved in the REST framework project, here are a few places you can start: - -* Contribute on [GitHub][github] by reviewing and submitting issues, and making pull requests. -* Join the [REST framework discussion group][group], and help build the community. -* Follow [the author][twitter] on Twitter and say hi. - -**Now go build awesome things.** - - -[repo]: https://github.com/tomchristie/rest-framework-tutorial -[sandbox]: http://restframework.herokuapp.com/ -[github]: https://github.com/tomchristie/django-rest-framework -[group]: https://groups.google.com/forum/?fromgroups#!forum/django-rest-framework -[twitter]: https://twitter.com/_tomchristie +[tut-7]: 7-schemas-and-client-libraries.md diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md new file mode 100644 index 000000000..8d772a5bf --- /dev/null +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -0,0 +1,216 @@ +# Tutorial 7: Schemas & client libraries + +A schema is a machine-readable document that describes the available API +endpoints, their URLS, and what operations they support. + +Schemas can be a useful tool for auto-generated documentation, and can also +be used to drive dynamic client libraries that can interact with the API. + +## Core API + +In order to provide schema support REST framework uses [Core API][coreapi]. + +Core API is a document specification for describing APIs. It is used to provide +an internal representation format of the available endpoints and possible +interactions that an API exposes. It can either be used server-side, or +client-side. + +When used server-side, Core API allows an API to support rendering to a wide +range of schema or hypermedia formats. + +When used client-side, Core API allows for dynamically driven client libraries +that can interact with any API that exposes a supported schema or hypermedia +format. + +## Adding a schema + +REST framework supports either explicitly defined schema views, or +automatically generated schemas. Since we're using viewsets and routers, +we can simply use the automatic schema generation. + +You'll need to install the `coreapi` python package in order to include an +API schema. + + $ pip install coreapi + +We can now include a schema for our API, by adding a `schema_title` argument to +the router instantiation. + + router = DefaultRouter(schema_title='Pastebin API') + +If you visit the API root endpoint in a browser you should now see `corejson` +representation become available as an option. + +![Schema format](../img/corejson-format.png) + +We can also request the schema from the command line, by specifying the desired +content type in the `Accept` header. + + $ http http://127.0.0.1:8000/ Accept:application/vnd.coreapi+json + HTTP/1.0 200 OK + Allow: GET, HEAD, OPTIONS + Content-Type: application/vnd.coreapi+json + + { + "_meta": { + "title": "Pastebin API" + }, + "_type": "document", + ... + +The default output style is to use the [Core JSON][corejson] encoding. + +Other schema formats, such as [Open API][openapi] (formerly Swagger) are +also supported. + +## Using a command line client + +Now that our API is exposing a schema endpoint, we can use a dynamic client +library to interact with the API. To demonstrate this, let's use the +Core API command line client. We've already installed the `coreapi` package +using `pip`, so the client tool should already be installed. Check that it +is available on the command line... + + $ coreapi + Usage: coreapi [OPTIONS] COMMAND [ARGS]... + + Command line client for interacting with CoreAPI services. + + Visit http://www.coreapi.org for more information. + + Options: + --version Display the package version number. + --help Show this message and exit. + + Commands: + ... + +First we'll load the API schema using the command line client. + + $ coreapi get http://127.0.0.1:8000/ + + snippets: { + highlight(pk) + list() + retrieve(pk) + } + users: { + list() + retrieve(pk) + } + +We haven't authenticated yet, so right now we're only able to see the read only +endpoints, in line with how we've set up the permissions on the API. + +Let's try listing the existing snippets, using the command line client: + + $ coreapi action snippets list + [ + { + "url": "http://127.0.0.1:8000/snippets/1/", + "highlight": "http://127.0.0.1:8000/snippets/1/highlight/", + "owner": "lucy", + "title": "Example", + "code": "print('hello, world!')", + "linenos": true, + "language": "python", + "style": "friendly" + }, + ... + +Some of the API endpoints require named parameters. For example, to get back +the highlight HTML for a particular snippet we need to provide an id. + + $ coreapi action snippets highlight --param pk 1 + + + + + Example + ... + +## Authenticating our client + +If we want to be able to create, edit and delete snippets, we'll need to +authenticate as a valid user. In this case we'll just use basic auth. + +Make sure to replace the `` and `` below with your +actual username and password. + + $ coreapi credentials add 127.0.0.1 : --auth basic + Added credentials + 127.0.0.1 "Basic <...>" + +Now if we fetch the schema again, we should be able to see the full +set of available interactions. + + $ coreapi reload + Pastebin API "http://127.0.0.1:8000/"> + snippets: { + create(code, [title], [linenos], [language], [style]) + destroy(pk) + highlight(pk) + list() + partial_update(pk, [title], [code], [linenos], [language], [style]) + retrieve(pk) + update(pk, code, [title], [linenos], [language], [style]) + } + users: { + list() + retrieve(pk) + } + +We're now able to interact with these endpoints. For example, to create a new +snippet: + + $ coreapi action snippets create --param title "Example" --param code "print('hello, world')" + { + "url": "http://127.0.0.1:8000/snippets/7/", + "id": 7, + "highlight": "http://127.0.0.1:8000/snippets/7/highlight/", + "owner": "lucy", + "title": "Example", + "code": "print('hello, world')", + "linenos": false, + "language": "python", + "style": "friendly" + } + +And to delete a snippet: + + $ coreapi action snippets destroy --param pk 7 + +As well as the command line client, developers can also interact with your +API using client libraries. The Python client library is the first of these +to be available, and a Javascript client library is planned to be released +soon. + +For more details on customizing schema generation and using Core API +client libraries you'll need to refer to the full documentation. + +## Reviewing our work + +With an incredibly small amount of code, we've now got a complete pastebin Web API, which is fully web browsable, includes a schema-driven client library, and comes complete with authentication, per-object permissions, and multiple renderer formats. + +We've walked through each step of the design process, and seen how if we need to customize anything we can gradually work our way down to simply using regular Django views. + +You can review the final [tutorial code][repo] on GitHub, or try out a live example in [the sandbox][sandbox]. + +## Onwards and upwards + +We've reached the end of our tutorial. If you want to get more involved in the REST framework project, here are a few places you can start: + +* Contribute on [GitHub][github] by reviewing and submitting issues, and making pull requests. +* Join the [REST framework discussion group][group], and help build the community. +* Follow [the author][twitter] on Twitter and say hi. + +**Now go build awesome things.** + +[coreapi]: http://www.coreapi.org +[corejson]: http://www.coreapi.org/specification/encoding/#core-json-encoding +[openapi]: https://openapis.org/ +[repo]: https://github.com/tomchristie/rest-framework-tutorial +[sandbox]: http://restframework.herokuapp.com/ +[github]: https://github.com/tomchristie/django-rest-framework +[group]: https://groups.google.com/forum/?fromgroups#!forum/django-rest-framework +[twitter]: https://twitter.com/_tomchristie diff --git a/mkdocs.yml b/mkdocs.yml index 19d1b3553..b10fbefb5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,6 +20,7 @@ pages: - '4 - Authentication and permissions': 'tutorial/4-authentication-and-permissions.md' - '5 - Relationships and hyperlinked APIs': 'tutorial/5-relationships-and-hyperlinked-apis.md' - '6 - Viewsets and routers': 'tutorial/6-viewsets-and-routers.md' + - '7 - Schemas and client libraries': 'tutorial/7-schemas-and-client-libraries.md' - API Guide: - 'Requests': 'api-guide/requests.md' - 'Responses': 'api-guide/responses.md' @@ -41,6 +42,7 @@ pages: - 'Versioning': 'api-guide/versioning.md' - 'Content negotiation': 'api-guide/content-negotiation.md' - 'Metadata': 'api-guide/metadata.md' + - 'Schemas': 'api-guide/schemas.md' - 'Format suffixes': 'api-guide/format-suffixes.md' - 'Returning URLs': 'api-guide/reverse.md' - 'Exceptions': 'api-guide/exceptions.md' @@ -49,6 +51,7 @@ pages: - 'Settings': 'api-guide/settings.md' - Topics: - 'Documenting your API': 'topics/documenting-your-api.md' + - 'API Clients': 'topics/api-clients.md' - 'Internationalization': 'topics/internationalization.md' - 'AJAX, CSRF & CORS': 'topics/ajax-csrf-cors.md' - 'HTML & Forms': 'topics/html-and-forms.md' diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 241e1951d..54c080491 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -2,3 +2,4 @@ markdown==2.6.4 django-guardian==1.4.3 django-filter==0.13.0 +coreapi==1.21.1 diff --git a/rest_framework/compat.py b/rest_framework/compat.py index dd30636f4..9c69eaa03 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -156,6 +156,16 @@ except ImportError: crispy_forms = None +# coreapi is optional (Note that uritemplate is a dependancy of coreapi) +try: + import coreapi + import uritemplate +except (ImportError, SyntaxError): + # SyntaxError is possible under python 3.2 + coreapi = None + uritemplate = None + + # Django-guardian is optional. Import only if guardian is in INSTALLED_APPS # Fixes (#1712). We keep the try/except for the test suite. guardian = None diff --git a/rest_framework/filters.py b/rest_framework/filters.py index fdd9519c6..caff1c17f 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -72,6 +72,9 @@ class BaseFilterBackend(object): """ raise NotImplementedError(".filter_queryset() must be overridden.") + def get_fields(self, view): + return [] + class DjangoFilterBackend(BaseFilterBackend): """ @@ -128,6 +131,17 @@ class DjangoFilterBackend(BaseFilterBackend): template = loader.get_template(self.template) return template_render(template, context) + def get_fields(self, view): + filter_class = getattr(view, 'filter_class', None) + if filter_class: + return list(filter_class().filters.keys()) + + filter_fields = getattr(view, 'filter_fields', None) + if filter_fields: + return filter_fields + + return [] + class SearchFilter(BaseFilterBackend): # The URL query parameter used for the search. @@ -217,6 +231,9 @@ class SearchFilter(BaseFilterBackend): template = loader.get_template(self.template) return template_render(template, context) + def get_fields(self, view): + return [self.search_param] + class OrderingFilter(BaseFilterBackend): # The URL query parameter used for the ordering. @@ -330,6 +347,9 @@ class OrderingFilter(BaseFilterBackend): context = self.get_template_context(request, queryset, view) return template_render(template, context) + def get_fields(self, view): + return [self.ordering_param] + class DjangoObjectPermissionsFilter(BaseFilterBackend): """ diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index a66c7505c..6ad10d860 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -157,6 +157,9 @@ class BasePagination(object): def get_results(self, data): return data['results'] + def get_fields(self, view): + return [] + class PageNumberPagination(BasePagination): """ @@ -280,6 +283,11 @@ class PageNumberPagination(BasePagination): context = self.get_html_context() return template_render(template, context) + def get_fields(self, view): + if self.page_size_query_param is None: + return [self.page_query_param] + return [self.page_query_param, self.page_size_query_param] + class LimitOffsetPagination(BasePagination): """ @@ -404,6 +412,9 @@ class LimitOffsetPagination(BasePagination): context = self.get_html_context() return template_render(template, context) + def get_fields(self, view): + return [self.limit_query_param, self.offset_query_param] + class CursorPagination(BasePagination): """ @@ -706,3 +717,6 @@ class CursorPagination(BasePagination): template = loader.get_template(self.template) context = self.get_html_context() return template_render(template, context) + + def get_fields(self, view): + return [self.cursor_query_param] diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 7ca680e74..e313998d1 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -22,7 +22,8 @@ from django.utils import six from rest_framework import VERSION, exceptions, serializers, status from rest_framework.compat import ( - INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, template_render + INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, coreapi, + template_render ) from rest_framework.exceptions import ParseError from rest_framework.request import is_form_media_type, override_method @@ -790,3 +791,17 @@ class MultiPartRenderer(BaseRenderer): "test case." % key ) return encode_multipart(self.BOUNDARY, data) + + +class CoreJSONRenderer(BaseRenderer): + media_type = 'application/vnd.coreapi+json' + charset = None + format = 'corejson' + + def __init__(self): + assert coreapi, 'Using CoreJSONRenderer, but `coreapi` is not installed.' + + def render(self, data, media_type=None, renderer_context=None): + indent = bool(renderer_context.get('indent', 0)) + codec = coreapi.codecs.CoreJSONCodec() + return codec.dump(data, indent=indent) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 70a1149ab..a71bb7791 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -22,9 +22,11 @@ from django.conf.urls import url from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import NoReverseMatch -from rest_framework import views +from rest_framework import exceptions, renderers, views from rest_framework.response import Response from rest_framework.reverse import reverse +from rest_framework.schemas import SchemaGenerator +from rest_framework.settings import api_settings from rest_framework.urlpatterns import format_suffix_patterns Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) @@ -255,6 +257,7 @@ class SimpleRouter(BaseRouter): lookup=lookup, trailing_slash=self.trailing_slash ) + view = viewset.as_view(mapping, **route.initkwargs) name = route.name.format(basename=basename) ret.append(url(regex, view, name=name)) @@ -270,8 +273,13 @@ class DefaultRouter(SimpleRouter): include_root_view = True include_format_suffixes = True root_view_name = 'api-root' + schema_renderers = [renderers.CoreJSONRenderer] - def get_api_root_view(self): + def __init__(self, *args, **kwargs): + self.schema_title = kwargs.pop('schema_title', None) + super(DefaultRouter, self).__init__(*args, **kwargs) + + def get_api_root_view(self, schema_urls=None): """ Return a view to use as the API root. """ @@ -280,10 +288,33 @@ class DefaultRouter(SimpleRouter): for prefix, viewset, basename in self.registry: api_root_dict[prefix] = list_name.format(basename=basename) + view_renderers = list(api_settings.DEFAULT_RENDERER_CLASSES) + schema_media_types = [] + + if schema_urls and self.schema_title: + view_renderers += list(self.schema_renderers) + schema_generator = SchemaGenerator( + title=self.schema_title, + patterns=schema_urls + ) + schema_media_types = [ + renderer.media_type + for renderer in self.schema_renderers + ] + class APIRoot(views.APIView): _ignore_model_permissions = True + renderer_classes = view_renderers def get(self, request, *args, **kwargs): + if request.accepted_renderer.media_type in schema_media_types: + # Return a schema response. + schema = schema_generator.get_schema(request) + if schema is None: + raise exceptions.PermissionDenied() + return Response(schema) + + # Return a plain {"name": "hyperlink"} response. ret = OrderedDict() namespace = request.resolver_match.namespace for key, url_name in api_root_dict.items(): @@ -310,15 +341,13 @@ class DefaultRouter(SimpleRouter): Generate the list of URL patterns, including a default root view for the API, and appending `.json` style format suffixes. """ - urls = [] + urls = super(DefaultRouter, self).get_urls() if self.include_root_view: - root_url = url(r'^$', self.get_api_root_view(), name=self.root_view_name) + view = self.get_api_root_view(schema_urls=urls) + root_url = url(r'^$', view, name=self.root_view_name) urls.append(root_url) - default_urls = super(DefaultRouter, self).get_urls() - urls.extend(default_urls) - if self.include_format_suffixes: urls = format_suffix_patterns(urls) diff --git a/rest_framework/schemas.py b/rest_framework/schemas.py new file mode 100644 index 000000000..cf84aca74 --- /dev/null +++ b/rest_framework/schemas.py @@ -0,0 +1,300 @@ +from importlib import import_module + +from django.conf import settings +from django.contrib.admindocs.views import simplify_regex +from django.core.urlresolvers import RegexURLPattern, RegexURLResolver +from django.utils import six + +from rest_framework import exceptions, serializers +from rest_framework.compat import coreapi, uritemplate +from rest_framework.request import clone_request +from rest_framework.views import APIView + + +def as_query_fields(items): + """ + Take a list of Fields and plain strings. + Convert any pain strings into `location='query'` Field instances. + """ + return [ + item if isinstance(item, coreapi.Field) else coreapi.Field(name=item, required=False, location='query') + for item in items + ] + + +def is_api_view(callback): + """ + Return `True` if the given view callback is a REST framework view/viewset. + """ + cls = getattr(callback, 'cls', None) + return (cls is not None) and issubclass(cls, APIView) + + +def insert_into(target, keys, item): + """ + Insert `item` into the nested dictionary `target`. + + For example: + + target = {} + insert_into(target, ('users', 'list'), Link(...)) + insert_into(target, ('users', 'detail'), Link(...)) + assert target == {'users': {'list': Link(...), 'detail': Link(...)}} + """ + for key in keys[:1]: + if key not in target: + target[key] = {} + target = target[key] + target[keys[-1]] = item + + +class SchemaGenerator(object): + default_mapping = { + 'get': 'read', + 'post': 'create', + 'put': 'update', + 'patch': 'partial_update', + 'delete': 'destroy', + } + + def __init__(self, title=None, patterns=None, urlconf=None): + assert coreapi, '`coreapi` must be installed for schema support.' + + if patterns is None and urlconf is not None: + if isinstance(urlconf, six.string_types): + urls = import_module(urlconf) + else: + urls = urlconf + patterns = urls.urlpatterns + elif patterns is None and urlconf is None: + urls = import_module(settings.ROOT_URLCONF) + patterns = urls.urlpatterns + + self.title = title + self.endpoints = self.get_api_endpoints(patterns) + + def get_schema(self, request=None): + if request is None: + endpoints = self.endpoints + else: + # Filter the list of endpoints to only include those that + # the user has permission on. + endpoints = [] + for key, link, callback in self.endpoints: + method = link.action.upper() + view = callback.cls() + view.request = clone_request(request, method) + try: + view.check_permissions(view.request) + except exceptions.APIException: + pass + else: + endpoints.append((key, link, callback)) + + if not endpoints: + return None + + # Generate the schema content structure, from the endpoints. + # ('users', 'list'), Link -> {'users': {'list': Link()}} + content = {} + for key, link, callback in endpoints: + insert_into(content, key, link) + + # Return the schema document. + return coreapi.Document(title=self.title, content=content) + + def get_api_endpoints(self, patterns, prefix=''): + """ + Return a list of all available API endpoints by inspecting the URL conf. + """ + api_endpoints = [] + + for pattern in patterns: + path_regex = prefix + pattern.regex.pattern + + if isinstance(pattern, RegexURLPattern): + path = self.get_path(path_regex) + callback = pattern.callback + if self.should_include_endpoint(path, callback): + for method in self.get_allowed_methods(callback): + key = self.get_key(path, method, callback) + link = self.get_link(path, method, callback) + endpoint = (key, link, callback) + api_endpoints.append(endpoint) + + elif isinstance(pattern, RegexURLResolver): + nested_endpoints = self.get_api_endpoints( + patterns=pattern.url_patterns, + prefix=path_regex + ) + api_endpoints.extend(nested_endpoints) + + return api_endpoints + + def get_path(self, path_regex): + """ + Given a URL conf regex, return a URI template string. + """ + path = simplify_regex(path_regex) + path = path.replace('<', '{').replace('>', '}') + return path + + def should_include_endpoint(self, path, callback): + """ + Return `True` if the given endpoint should be included. + """ + if not is_api_view(callback): + return False # Ignore anything except REST framework views. + + if path.endswith('.{format}') or path.endswith('.{format}/'): + return False # Ignore .json style URLs. + + if path == '/': + return False # Ignore the root endpoint. + + return True + + def get_allowed_methods(self, callback): + """ + Return a list of the valid HTTP methods for this endpoint. + """ + if hasattr(callback, 'actions'): + return [method.upper() for method in callback.actions.keys()] + + return [ + method for method in + callback.cls().allowed_methods if method != 'OPTIONS' + ] + + def get_key(self, path, method, callback): + """ + Return a tuple of strings, indicating the identity to use for a + given endpoint. eg. ('users', 'list'). + """ + category = None + for item in path.strip('/').split('/'): + if '{' in item: + break + category = item + + actions = getattr(callback, 'actions', self.default_mapping) + action = actions[method.lower()] + + if category: + return (category, action) + return (action,) + + # Methods for generating each individual `Link` instance... + + def get_link(self, path, method, callback): + """ + Return a `coreapi.Link` instance for the given endpoint. + """ + view = callback.cls() + + fields = self.get_path_fields(path, method, callback, view) + fields += self.get_serializer_fields(path, method, callback, view) + fields += self.get_pagination_fields(path, method, callback, view) + fields += self.get_filter_fields(path, method, callback, view) + + if fields and any([field.location in ('form', 'body') for field in fields]): + encoding = self.get_encoding(path, method, callback, view) + else: + encoding = None + + return coreapi.Link( + url=path, + action=method.lower(), + encoding=encoding, + fields=fields + ) + + def get_encoding(self, path, method, callback, view): + """ + Return the 'encoding' parameter to use for a given endpoint. + """ + # Core API supports the following request encodings over HTTP... + supported_media_types = set(( + 'application/json', + 'application/x-www-form-urlencoded', + 'multipart/form-data', + )) + parser_classes = getattr(view, 'parser_classes', []) + for parser_class in parser_classes: + media_type = getattr(parser_class, 'media_type', None) + if media_type in supported_media_types: + return media_type + # Raw binary uploads are supported with "application/octet-stream" + if media_type == '*/*': + return 'application/octet-stream' + + return None + + def get_path_fields(self, path, method, callback, view): + """ + Return a list of `coreapi.Field` instances corresponding to any + templated path variables. + """ + fields = [] + + for variable in uritemplate.variables(path): + field = coreapi.Field(name=variable, location='path', required=True) + fields.append(field) + + return fields + + def get_serializer_fields(self, path, method, callback, view): + """ + Return a list of `coreapi.Field` instances corresponding to any + request body input, as determined by the serializer class. + """ + if method not in ('PUT', 'PATCH', 'POST'): + return [] + + fields = [] + + serializer_class = view.get_serializer_class() + serializer = serializer_class() + + if isinstance(serializer, serializers.ListSerializer): + return coreapi.Field(name='data', location='body', required=True) + + if not isinstance(serializer, serializers.Serializer): + return [] + + for field in serializer.fields.values(): + if field.read_only: + continue + required = field.required and method != 'PATCH' + field = coreapi.Field(name=field.source, location='form', required=required) + fields.append(field) + + return fields + + def get_pagination_fields(self, path, method, callback, view): + if method != 'GET': + return [] + + if hasattr(callback, 'actions') and ('list' not in callback.actions.values()): + return [] + + if not hasattr(view, 'pagination_class'): + return [] + + paginator = view.pagination_class() + return as_query_fields(paginator.get_fields(view)) + + def get_filter_fields(self, path, method, callback, view): + if method != 'GET': + return [] + + if hasattr(callback, 'actions') and ('list' not in callback.actions.values()): + return [] + + if not hasattr(view, 'filter_backends'): + return [] + + fields = [] + for filter_backend in view.filter_backends: + fields += as_query_fields(filter_backend().get_fields(view)) + return fields diff --git a/rest_framework/utils/encoders.py b/rest_framework/utils/encoders.py index f883b4925..e5b52ea5f 100644 --- a/rest_framework/utils/encoders.py +++ b/rest_framework/utils/encoders.py @@ -13,7 +13,7 @@ from django.utils import six, timezone from django.utils.encoding import force_text from django.utils.functional import Promise -from rest_framework.compat import total_seconds +from rest_framework.compat import coreapi, total_seconds class JSONEncoder(json.JSONEncoder): @@ -64,4 +64,9 @@ class JSONEncoder(json.JSONEncoder): pass elif hasattr(obj, '__iter__'): return tuple(item for item in obj) + elif (coreapi is not None) and isinstance(obj, (coreapi.Document, coreapi.Error)): + raise RuntimeError( + 'Cannot return a coreapi object from a JSON view. ' + 'You should be using a schema renderer instead for this view.' + ) return super(JSONEncoder, self).default(obj) diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 05434b72e..7687448c4 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -98,6 +98,7 @@ class ViewSetMixin(object): # resolved URL. view.cls = cls view.suffix = initkwargs.get('suffix', None) + view.actions = actions return csrf_exempt(view) def initialize_request(self, request, *args, **kwargs): diff --git a/runtests.py b/runtests.py index 1627e33b2..e97ac0367 100755 --- a/runtests.py +++ b/runtests.py @@ -14,7 +14,7 @@ PYTEST_ARGS = { FLAKE8_ARGS = ['rest_framework', 'tests', '--ignore=E501'] -ISORT_ARGS = ['--recursive', '--check-only', '-p', 'tests', 'rest_framework', 'tests'] +ISORT_ARGS = ['--recursive', '--check-only', '-o' 'uritemplate', '-p', 'tests', 'rest_framework', 'tests'] sys.path.append(os.path.dirname(__file__)) diff --git a/schema-support b/schema-support new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_routers.py b/tests/test_routers.py index acab660d8..f45039f80 100644 --- a/tests/test_routers.py +++ b/tests/test_routers.py @@ -257,7 +257,7 @@ class TestNameableRoot(TestCase): def test_router_has_custom_name(self): expected = 'nameable-root' - self.assertEqual(expected, self.urls[0].name) + self.assertEqual(expected, self.urls[-1].name) class TestActionKeywordArgs(TestCase): diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 000000000..7d3308ed9 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,137 @@ +import unittest + +from django.conf.urls import include, url +from django.test import TestCase, override_settings + +from rest_framework import filters, pagination, permissions, serializers +from rest_framework.compat import coreapi +from rest_framework.routers import DefaultRouter +from rest_framework.test import APIClient +from rest_framework.viewsets import ModelViewSet + + +class MockUser(object): + def is_authenticated(self): + return True + + +class ExamplePagination(pagination.PageNumberPagination): + page_size = 100 + + +class ExampleSerializer(serializers.Serializer): + a = serializers.CharField(required=True) + b = serializers.CharField(required=False) + + +class ExampleViewSet(ModelViewSet): + pagination_class = ExamplePagination + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + filter_backends = [filters.OrderingFilter] + serializer_class = ExampleSerializer + + +router = DefaultRouter(schema_title='Example API' if coreapi else None) +router.register('example', ExampleViewSet, base_name='example') +urlpatterns = [ + url(r'^', include(router.urls)) +] + + +@unittest.skipUnless(coreapi, 'coreapi is not installed') +@override_settings(ROOT_URLCONF='tests.test_schemas') +class TestRouterGeneratedSchema(TestCase): + def test_anonymous_request(self): + client = APIClient() + response = client.get('/', HTTP_ACCEPT='application/vnd.coreapi+json') + self.assertEqual(response.status_code, 200) + expected = coreapi.Document( + url='', + title='Example API', + content={ + 'example': { + 'list': coreapi.Link( + url='/example/', + action='get', + fields=[ + coreapi.Field('page', required=False, location='query'), + coreapi.Field('ordering', required=False, location='query') + ] + ), + 'retrieve': coreapi.Link( + url='/example/{pk}/', + action='get', + fields=[ + coreapi.Field('pk', required=True, location='path') + ] + ) + } + } + ) + self.assertEqual(response.data, expected) + + def test_authenticated_request(self): + client = APIClient() + client.force_authenticate(MockUser()) + response = client.get('/', HTTP_ACCEPT='application/vnd.coreapi+json') + self.assertEqual(response.status_code, 200) + expected = coreapi.Document( + url='', + title='Example API', + content={ + 'example': { + 'list': coreapi.Link( + url='/example/', + action='get', + fields=[ + coreapi.Field('page', required=False, location='query'), + coreapi.Field('ordering', required=False, location='query') + ] + ), + 'create': coreapi.Link( + url='/example/', + action='post', + encoding='application/json', + fields=[ + coreapi.Field('a', required=True, location='form'), + coreapi.Field('b', required=False, location='form') + ] + ), + 'retrieve': coreapi.Link( + url='/example/{pk}/', + action='get', + fields=[ + coreapi.Field('pk', required=True, location='path') + ] + ), + 'update': coreapi.Link( + url='/example/{pk}/', + action='put', + encoding='application/json', + fields=[ + coreapi.Field('pk', required=True, location='path'), + coreapi.Field('a', required=True, location='form'), + coreapi.Field('b', required=False, location='form') + ] + ), + 'partial_update': coreapi.Link( + url='/example/{pk}/', + action='patch', + encoding='application/json', + fields=[ + coreapi.Field('pk', required=True, location='path'), + coreapi.Field('a', required=False, location='form'), + coreapi.Field('b', required=False, location='form') + ] + ), + 'destroy': coreapi.Link( + url='/example/{pk}/', + action='delete', + fields=[ + coreapi.Field('pk', required=True, location='path') + ] + ) + } + } + ) + self.assertEqual(response.data, expected)