diff options
| author | Jamie Matthews | 2012-09-26 13:05:21 +0100 |
|---|---|---|
| committer | Jamie Matthews | 2012-09-26 13:05:21 +0100 |
| commit | 01770c53cd9045e6ea054f32b1e40b5d2ff7fe44 (patch) | |
| tree | 657cb66f92d78add3b2f587754387832043168e6 /docs | |
| parent | f6488cb0589d3b11fb8d831e00d1389f3fff74b6 (diff) | |
| parent | 09a445b257532be69ffab69a3f62b84bfa90463d (diff) | |
| download | django-rest-framework-01770c53cd9045e6ea054f32b1e40b5d2ff7fe44.tar.bz2 | |
Merge branch 'restframework2' of git://github.com/tomchristie/django-rest-framework into improved-view-decorators
* 'restframework2' of git://github.com/tomchristie/django-rest-framework: (56 commits)
Bits of cleanup
Add request.QUERY_PARAMS
Add readonly 'id' field
Tweak browseable API
Don't display readonly fields
Fix some bits of serialization
Add csrf note
Fix incorrect bit of tutorial
Added tox.ini
Tweak media_type -> accepted_media_type. Need to document, but marginally less confusing
Tweak media_type -> accepted_media_type. Need to document, but marginally less confusing
Tweak media_type -> accepted_media_type. Need to document, but marginally less confusing
Clean up bits of templates etc
Hack out bunch of unneccesary private methods on View class
Clean up template tags
Remove dumbass __all__ variables
Remove old 'djangorestframework directories
Change package name: djangorestframework -> rest_framework
Dont strip final '/'
Use get_script_prefix to play nicely if not installed at the root.
...
Conflicts:
rest_framework/decorators.py
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/api-guide/authentication.md | 14 | ||||
| -rw-r--r-- | docs/api-guide/content-negotiation.md | 2 | ||||
| -rw-r--r-- | docs/api-guide/permissions.md | 6 | ||||
| -rw-r--r-- | docs/api-guide/requests.md | 6 | ||||
| -rw-r--r-- | docs/api-guide/reverse.md | 6 | ||||
| -rw-r--r-- | docs/api-guide/settings.md | 28 | ||||
| -rw-r--r-- | docs/api-guide/status-codes.md | 2 | ||||
| -rw-r--r-- | docs/api-guide/throttling.md | 19 | ||||
| -rw-r--r-- | docs/index.md | 15 | ||||
| -rw-r--r-- | docs/template.html | 3 | ||||
| -rw-r--r-- | docs/topics/browsable-api.md | 97 | ||||
| -rw-r--r-- | docs/topics/changelog.md | 107 | ||||
| -rw-r--r-- | docs/topics/credits.md | 2 | ||||
| -rw-r--r-- | docs/topics/rest-hypermedia-hateoas.md | 52 | ||||
| -rw-r--r-- | docs/tutorial/1-serialization.md | 32 | ||||
| -rw-r--r-- | docs/tutorial/2-requests-and-responses.md | 32 | ||||
| -rw-r--r-- | docs/tutorial/3-class-based-views.md | 51 | ||||
| -rw-r--r-- | docs/tutorial/4-authentication-permissions-and-throttling.md | 6 | ||||
| -rw-r--r-- | docs/tutorial/5-relationships-and-hyperlinked-apis.md | 6 | ||||
| -rw-r--r-- | docs/tutorial/6-resource-orientated-projects.md | 57 |
20 files changed, 425 insertions, 118 deletions
diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 79950946..f24c6a81 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -28,10 +28,10 @@ The value of `request.user` and `request.auth` for unauthenticated requests can The default authentication policy may be set globally, using the `DEFAULT_AUTHENTICATION` setting. For example. - API_SETTINGS = { + REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION': ( - 'djangorestframework.authentication.UserBasicAuthentication', - 'djangorestframework.authentication.SessionAuthentication', + 'rest_framework.authentication.UserBasicAuthentication', + 'rest_framework.authentication.SessionAuthentication', ) } @@ -75,11 +75,11 @@ If successfully authenticated, `BasicAuthentication` provides the following cred This policy uses a simple token-based HTTP Authentication scheme. Token authentication is appropriate for client-server setups, such as native desktop and mobile clients. -To use the `TokenAuthentication` policy, include `djangorestframework.authtoken` in your `INSTALLED_APPS` setting. +To use the `TokenAuthentication` policy, include `rest_framework.authtoken` in your `INSTALLED_APPS` setting. You'll also need to create tokens for your users. - from djangorestframework.authtoken.models import Token + from rest_framework.authtoken.models import Token token = Token.objects.create(user=...) print token.key @@ -91,7 +91,7 @@ For clients to authenticate, the token key should be included in the `Authorizat If successfully authenticated, `TokenAuthentication` provides the following credentials. * `request.user` will be a `django.contrib.auth.models.User` instance. -* `request.auth` will be a `djangorestframework.tokenauth.models.BasicToken` instance. +* `request.auth` will be a `rest_framework.tokenauth.models.BasicToken` instance. **Note:** If you use `TokenAuthentication` in production you must ensure that your API is only available over `https` only. @@ -102,7 +102,7 @@ This policy uses the [OAuth 2.0][oauth] protocol to authenticate requests. OAut If successfully authenticated, `OAuthAuthentication` provides the following credentials. * `request.user` will be a `django.contrib.auth.models.User` instance. -* `request.auth` will be a `djangorestframework.models.OAuthToken` instance. +* `request.auth` will be a `rest_framework.models.OAuthToken` instance. ## SessionAuthentication diff --git a/docs/api-guide/content-negotiation.md b/docs/api-guide/content-negotiation.md index 01895a4b..ad98de3b 100644 --- a/docs/api-guide/content-negotiation.md +++ b/docs/api-guide/content-negotiation.md @@ -1,3 +1,5 @@ +<a class="github" href="negotiation.py"></a> + # Content negotiation > HTTP has provisions for several mechanisms for "content negotiation" - the process of selecting the best representation for a given response when there are multiple representations available. diff --git a/docs/api-guide/permissions.md b/docs/api-guide/permissions.md index fafef305..e0ceb1ea 100644 --- a/docs/api-guide/permissions.md +++ b/docs/api-guide/permissions.md @@ -27,9 +27,9 @@ Object level permissions are run by REST framework's generic views when `.get_ob The default permission policy may be set globally, using the `DEFAULT_PERMISSIONS` setting. For example. - API_SETTINGS = { + REST_FRAMEWORK = { 'DEFAULT_PERMISSIONS': ( - 'djangorestframework.permissions.IsAuthenticated', + 'rest_framework.permissions.IsAuthenticated', ) } @@ -97,4 +97,4 @@ The method should return `True` if the request should be granted access, and `Fa [authentication]: authentication.md [throttling]: throttling.md [contribauth]: https://docs.djangoproject.com/en/1.0/topics/auth/#permissions -[guardian]: https://github.com/lukaszb/django-guardian
\ No newline at end of file +[guardian]: https://github.com/lukaszb/django-guardian diff --git a/docs/api-guide/requests.md b/docs/api-guide/requests.md index 6746bb20..b223da80 100644 --- a/docs/api-guide/requests.md +++ b/docs/api-guide/requests.md @@ -49,7 +49,7 @@ This allows you to support file uploads from multiple content-types. For exampl `request.parsers` may no longer be altered once `request.DATA`, `request.FILES` or `request.POST` have been accessed. -If you're using the `djangorestframework.views.View` class... **[TODO]** +If you're using the `rest_framework.views.View` class... **[TODO]** ## .stream @@ -63,6 +63,6 @@ You will not typically need to access `request.stream`, unless you're writing a `request.authentication` may no longer be altered once `request.user` or `request.auth` have been accessed. -If you're using the `djangorestframework.views.View` class... **[TODO]** +If you're using the `rest_framework.views.View` class... **[TODO]** -[cite]: https://groups.google.com/d/topic/django-developers/dxI4qVzrBY4/discussion
\ No newline at end of file +[cite]: https://groups.google.com/d/topic/django-developers/dxI4qVzrBY4/discussion diff --git a/docs/api-guide/reverse.md b/docs/api-guide/reverse.md index f3cb0c64..3fa654c0 100644 --- a/docs/api-guide/reverse.md +++ b/docs/api-guide/reverse.md @@ -23,8 +23,8 @@ There's no requirement for you to use them, but if you do then the self-describi Has the same behavior as [`django.core.urlresolvers.reverse`][reverse], except that it returns a fully qualified URL, using the request to determine the host and port. - from djangorestframework.utils import reverse - from djangorestframework.views import APIView + from rest_framework.utils import reverse + from rest_framework.views import APIView class MyView(APIView): def get(self, request): @@ -40,4 +40,4 @@ Has the same behavior as [`django.core.urlresolvers.reverse_lazy`][reverse-lazy] [cite]: http://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm#sec_5_1_5 [reverse]: https://docs.djangoproject.com/en/dev/topics/http/urls/#reverse -[reverse-lazy]: https://docs.djangoproject.com/en/dev/topics/http/urls/#reverse-lazy
\ No newline at end of file +[reverse-lazy]: https://docs.djangoproject.com/en/dev/topics/http/urls/#reverse-lazy diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md index 2513928c..0f66e85e 100644 --- a/docs/api-guide/settings.md +++ b/docs/api-guide/settings.md @@ -6,16 +6,16 @@ > > — [The Zen of Python][cite] -Configuration for REST framework is all namespaced inside a single Django setting, named `API_SETTINGS`. +Configuration for REST framework is all namespaced inside a single Django setting, named `REST_FRAMEWORK`. For example your project's `settings.py` file might include something like this: - API_SETTINGS = { + REST_FRAMEWORK = { 'DEFAULT_RENDERERS': ( - 'djangorestframework.renderers.YAMLRenderer', + 'rest_framework.renderers.YAMLRenderer', ) 'DEFAULT_PARSERS': ( - 'djangorestframework.parsers.YAMLParser', + 'rest_framework.parsers.YAMLParser', ) } @@ -24,7 +24,7 @@ For example your project's `settings.py` file might include something like this: If you need to access the values of REST framework's API settings in your project, you should use the `api_settings` object. For example. - from djangorestframework.settings import api_settings + from rest_framework.settings import api_settings print api_settings.DEFAULT_AUTHENTICATION @@ -37,9 +37,9 @@ A list or tuple of renderer classes, that determines the default set of renderer Default: ( - 'djangorestframework.renderers.JSONRenderer', - 'djangorestframework.renderers.DocumentingHTMLRenderer' - 'djangorestframework.renderers.TemplateHTMLRenderer' + 'rest_framework.renderers.JSONRenderer', + 'rest_framework.renderers.DocumentingHTMLRenderer' + 'rest_framework.renderers.TemplateHTMLRenderer' ) ## DEFAULT_PARSERS @@ -49,8 +49,8 @@ A list or tuple of parser classes, that determines the default set of parsers us Default: ( - 'djangorestframework.parsers.JSONParser', - 'djangorestframework.parsers.FormParser' + 'rest_framework.parsers.JSONParser', + 'rest_framework.parsers.FormParser' ) ## DEFAULT_AUTHENTICATION @@ -60,8 +60,8 @@ A list or tuple of authentication classes, that determines the default set of au Default: ( - 'djangorestframework.authentication.SessionAuthentication', - 'djangorestframework.authentication.UserBasicAuthentication' + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.UserBasicAuthentication' ) ## DEFAULT_PERMISSIONS @@ -80,13 +80,13 @@ Default: `()` **TODO** -Default: `djangorestframework.serializers.ModelSerializer` +Default: `rest_framework.serializers.ModelSerializer` ## DEFAULT_PAGINATION_SERIALIZER **TODO** -Default: `djangorestframework.pagination.PaginationSerializer` +Default: `rest_framework.pagination.PaginationSerializer` ## FORMAT_SUFFIX_KWARG diff --git a/docs/api-guide/status-codes.md b/docs/api-guide/status-codes.md index 6693c79f..401f45ce 100644 --- a/docs/api-guide/status-codes.md +++ b/docs/api-guide/status-codes.md @@ -8,7 +8,7 @@ Using bare status codes in your responses isn't recommended. REST framework includes a set of named constants that you can use to make more code more obvious and readable. - from djangorestframework import status + from rest_framework import status def empty_view(self): content = {'please move along': 'nothing to see here'} diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index d1e34dcd..7861e9ba 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -29,10 +29,10 @@ If any throttle check fails an `exceptions.Throttled` exception will be raised, The default throttling policy may be set globally, using the `DEFAULT_THROTTLES` and `DEFAULT_THROTTLE_RATES` settings. For example. - API_SETTINGS = { + REST_FRAMEWORK = { 'DEFAULT_THROTTLES': ( - 'djangorestframework.throttles.AnonThrottle', - 'djangorestframework.throttles.UserThrottle', + 'rest_framework.throttles.AnonThrottle', + 'rest_framework.throttles.UserThrottle', ) 'DEFAULT_THROTTLE_RATES': { 'anon': '100/day', @@ -76,7 +76,7 @@ The allowed request rate is determined from one of the following (in order of pr ## UserRateThrottle -The `UserThrottle` will throttle users to a given rate of requests across the API. The user id is used to generate a unique key to throttle against. Unauthenticted requests will fall back to using the IP address of the incoming request is used to generate a unique key to throttle against. +The `UserThrottle` will throttle users to a given rate of requests across the API. The user id is used to generate a unique key to throttle against. Unauthenticted requests will fall back to using the IP address of the incoming request to generate a unique key to throttle against. The allowed request rate is determined from one of the following (in order of preference). @@ -95,7 +95,7 @@ For example, multiple user throttle rates could be implemented by using the foll ...and the following settings. - API_SETTINGS = { + REST_FRAMEWORK = { 'DEFAULT_THROTTLES': ( 'example.throttles.BurstRateThrottle', 'example.throttles.SustainedRateThrottle', @@ -106,7 +106,7 @@ For example, multiple user throttle rates could be implemented by using the foll } } -`UserThrottle` is suitable if you want a simple global rate restriction per-user. +`UserThrottle` is suitable if you want simple global rate restrictions per-user. ## ScopedRateThrottle @@ -124,16 +124,15 @@ For example, given the following views... throttle_scope = 'contacts' ... - class UploadView(APIView): throttle_scope = 'uploads' ... ...and the following settings. - API_SETTINGS = { + REST_FRAMEWORK = { 'DEFAULT_THROTTLES': ( - 'djangorestframework.throttles.ScopedRateThrottle', + 'rest_framework.throttles.ScopedRateThrottle', ) 'DEFAULT_THROTTLE_RATES': { 'contacts': '1000/day', @@ -149,4 +148,4 @@ To create a custom throttle, override `BaseThrottle` and implement `.allow_reque Optionally you may also override the `.wait()` method. If implemented, `.wait()` should return a recomended number of seconds to wait before attempting the next request, or `None`. The `.wait()` method will only be called if `.check_throttle()` has previously returned `False`. -[permissions]: permissions.md
\ No newline at end of file +[permissions]: permissions.md diff --git a/docs/index.md b/docs/index.md index 78f4674f..e7db5dbc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,6 @@ REST framework requires the following: * Python (2.6, 2.7) * Django (1.3, 1.4, 1.5) -* [URLObject][urlobject] (2.0.0+) The following packages are optional: @@ -41,20 +40,22 @@ Install using `pip`, including any optional packages you want... pip install -r requirements.txt pip install -r optionals.txt -Add `djangorestframework` to your `INSTALLED_APPS`. +Add `rest_framework` to your `INSTALLED_APPS`. INSTALLED_APPS = ( ... - 'djangorestframework', + 'rest_framework', ) If you're intending to use the browserable API you'll want to add REST framework's login and logout views. Add the following to your root `urls.py` file. urlpatterns = patterns('', ... - url(r'^api-auth/', include('djangorestframework.urls', namespace='djangorestframework')) + url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')) ) - + +Note that the base URL can be whatever you want, but you must include `rest_framework.urls` with the `rest_framework` namespace. + ## Quickstart **TODO** @@ -97,6 +98,7 @@ General guides to using REST framework. * [CSRF][csrf] * [Form overloading][formoverloading] +* [Working with the Browsable API][browsableapi] * [Contributing to REST framework][contributing] * [Credits][credits] @@ -110,7 +112,7 @@ Build the docs: Run the tests: - ./djangorestframework/runtests/runtests.py + ./rest_framework/runtests/runtests.py ## License @@ -168,5 +170,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [csrf]: topics/csrf.md [formoverloading]: topics/formoverloading.md +[browsableapi]: topics/browsable-api.md [contributing]: topics/contributing.md [credits]: topics/credits.md diff --git a/docs/template.html b/docs/template.html index e6c9078a..4ac94f40 100644 --- a/docs/template.html +++ b/docs/template.html @@ -68,6 +68,7 @@ <ul class="dropdown-menu"> <li><a href="{{ base_url }}/topics/csrf{{ suffix }}">Working with AJAX and CSRF</a></li> <li><a href="{{ base_url }}/topics/formoverloading{{ suffix }}">Browser based PUT, PATCH and DELETE</a></li> + <li><a href="{{ base_url }}/topics/browsable-api{{ suffix }}">Working with the browsable API</a></li> <li><a href="{{ base_url }}/topics/contributing{{ suffix }}">Contributing to REST framework</a></li> <li><a href="{{ base_url }}/topics/credits{{ suffix }}">Credits</a></li> </ul> @@ -117,7 +118,7 @@ if (location.hash) shiftWindow(); window.addEventListener("hashchange", shiftWindow); - $('.dropdown-menu').click(function(event) { + $('.dropdown-menu').on('click touchstart', function(event) { event.stopPropagation(); }); </script> diff --git a/docs/topics/browsable-api.md b/docs/topics/browsable-api.md new file mode 100644 index 00000000..ed27752f --- /dev/null +++ b/docs/topics/browsable-api.md @@ -0,0 +1,97 @@ +# Working with the Browsable API + +API may stand for Application *Programming* Interface, but humans have to be able to read the APIs, too; someone has to do the programming. Django REST Framework supports generating human-friendly HTML output for each resource when the `HTML` format is requested. These pages allow for easy browsing of resources, as well as forms for submitting data to the resources using `POST`, `PUT`, and `DELETE`. + +## URLs + +If you include fully-qualified URLs in your resource output, they will be 'urlized' and made clickable for easy browsing by humans. The `rest_framework` package includes a [`reverse`][drfreverse] helper for this purpose. + + +## Formats + +By default, the API will return the format specified by the headers, which in the case of the browser is HTML. The format can be specified using `?format=` in the request, so you can look at the raw JSON response in a browser by adding `?format=json` to the URL. There are helpful extensions for viewing JSON in [Firefox][ffjsonview] and [Chrome][chromejsonview]. + + +## Customizing + +To customize the look-and-feel, create a template called `api.html` and add it to your project, eg: `templates/rest_framework/api.html`, that extends the `rest_framework/base.html` template. + +The included browsable API template is built with [Bootstrap (2.1.1)][bootstrap], making it easy to customize the look-and-feel. + +### Theme + +To replace the theme wholesale, add a `bootstrap_theme` block to your `api.html` and insert a `link` to the desired Bootstrap theme css file. This will completely replace the included theme. + + {% block bootstrap_theme %} + <link rel="stylesheet" href="/path/to/my/bootstrap.css" type="text/css"> + {% endblock %} + +A suitable replacement theme can be generated using Bootstrap's [Customize Tool][bcustomize]. Also, there are pre-made themes available at [Bootswatch][bswatch]. To use any of the Bootswatch themes, simply download the theme's `bootstrap.min.css` file, add it to your project, and replace the default one as described above. + +You can also change the navbar variant, which by default is `navbar-inverse`, using the `bootstrap_navbar_variant` block. The empty `{% block bootstrap_navbar_variant %}{% endblock %}` will use the original Bootstrap navbar style. + +For more specific CSS tweaks, use the `extra_style` block instead. + + +### Blocks + +All of the blocks available in the browsable API base template that can be used in your `api.html`. + +* `blockbots` - `<meta>` tag that blocks crawlers +* `bodyclass` - (empty) class attribute for the `<body>` +* `bootstrap_theme` - CSS for the Bootstrap theme +* `bootstrap_navbar_variant` - CSS class for the navbar +* `branding` - section of the navbar, see [Bootstrap components][bcomponentsnav] +* `breadcrumbs` - Links showing resource nesting, allowing the user to go back up the resources. It's recommended to preserve these, but they can be overridden using the breadcrumbs block. +* `extrastyle` - (empty) extra CSS for the page +* `extrahead` - (empty) extra markup for the page `<head>` +* `footer` - Any copyright notices or similar footer materials can go here (by default right-aligned) +* `global_heading` - (empty) Use to insert content below the header but before the breadcrumbs. +* `title` - title of the page +* `userlinks` - This is a list of links on the right of the header, by default containing login/logout links. To add links instead of replace, use {{ block.super }} to preserve the authentication links. + +#### Components + +All of the [Bootstrap components][bcomponents] are available. + +##### Tooltips + +The browsable API makes use of the Bootstrap tooltips component. Any element with the `js-tooltip` class and a `title` attribute has that title content displayed in a tooltip on hover after a 1000ms delay. + + +### Advanced Customization + +#### Context + +The context that's available to the template: + +* `allowed_methods` : A list of methods allowed by the resource +* `api_settings` : The API settings +* `available_formats` : A list of formats allowed by the resource +* `breadcrumblist` : The list of links following the chain of nested resources +* `content` : The content of the API response +* `description` : The description of the resource, generated from its docstring +* `name` : The name of the resource +* `post_form` : A form instance for use by the POST form (if allowed) +* `put_form` : A form instance for use by the PUT form (if allowed) +* `request` : The request object +* `response` : The response object +* `version` : The version of Django REST Framework +* `view` : The view handling the request +* `FORMAT_PARAM` : The view can accept a format override +* `METHOD_PARAM` : The view can accept a method override + +#### Not using base.html + +For more advanced customization, such as not having a Bootstrap basis or tighter integration with the rest of your site, you can simply choose not to have `api.html` extend `base.html`. Then the page content and capabilities are entirely up to you. + + +[drfreverse]: ../api-guide/reverse.md +[ffjsonview]: https://addons.mozilla.org/en-US/firefox/addon/jsonview/ +[chromejsonview]: https://chrome.google.com/webstore/detail/chklaanhfefbnpoihckbnefhakgolnmc +[bootstrap]: http://getbootstrap.com +[bcustomize]: http://twitter.github.com/bootstrap/customize.html#variables +[bswatch]: http://bootswatch.com/ +[bcomponents]: http://twitter.github.com/bootstrap/components.html +[bcomponentsnav]: http://twitter.github.com/bootstrap/components.html#navbar + diff --git a/docs/topics/changelog.md b/docs/topics/changelog.md new file mode 100644 index 00000000..a4fd39e2 --- /dev/null +++ b/docs/topics/changelog.md @@ -0,0 +1,107 @@ +# Release Notes + +## 2.0.0 + +**TODO:** Explain REST framework 2.0 + +## 0.4.0 + +* Supports Django 1.5. +* Fixes issues with 'HEAD' method. +* Allow views to specify template used by TemplateRenderer +* More consistent error responses +* Some serializer fixes +* Fix internet explorer ajax behaviour +* Minor xml and yaml fixes +* Improve setup (eg use staticfiles, not the defunct ADMIN_MEDIA_PREFIX) +* Sensible absolute URL generation, not using hacky set_script_prefix + +## 0.3.3 + +* Added DjangoModelPermissions class to support `django.contrib.auth` style permissions. +* Use `staticfiles` for css files. + - Easier to override. Won't conflict with customised admin styles (eg grappelli) +* Templates are now nicely namespaced. + - Allows easier overriding. +* Drop implied 'pk' filter if last arg in urlconf is unnamed. + - Too magical. Explict is better than implicit. +* Saner template variable autoescaping. +* Tider setup.py +* Updated for URLObject 2.0 +* Bugfixes: + - Bug with PerUserThrottling when user contains unicode chars. + +## 0.3.2 + +* Bugfixes: + * Fix 403 for POST and PUT from the UI with UserLoggedInAuthentication (#115) + * serialize_model method in serializer.py may cause wrong value (#73) + * Fix Error when clicking OPTIONS button (#146) + * And many other fixes +* Remove short status codes + - Zen of Python: "There should be one-- and preferably only one --obvious way to do it." +* get_name, get_description become methods on the view - makes them overridable. +* Improved model mixin API - Hooks for build_query, get_instance_data, get_model, get_queryset, get_ordering + +## 0.3.1 + +* [not documented] + +## 0.3.0 + +* JSONP Support +* Bugfixes, including support for latest markdown release + +## 0.2.4 + +* Fix broken IsAdminUser permission. +* OPTIONS support. +* XMLParser. +* Drop mentions of Blog, BitBucket. + +## 0.2.3 + +* Fix some throttling bugs. +* ``X-Throttle`` header on throttling. +* Support for nesting resources on related models. + +## 0.2.2 + +* Throttling support complete. + +## 0.2.1 + +* Couple of simple bugfixes over 0.2.0 + +## 0.2.0 + +* Big refactoring changes since 0.1.0, ask on the discussion group if anything isn't clear. + The public API has been massively cleaned up. Expect it to be fairly stable from here on in. + +* ``Resource`` becomes decoupled into ``View`` and ``Resource``, your views should now inherit from ``View``, not ``Resource``. + +* The handler functions on views ``.get() .put() .post()`` etc, no longer have the ``content`` and ``auth`` args. + Use ``self.CONTENT`` inside a view to access the deserialized, validated content. + Use ``self.user`` inside a view to access the authenticated user. + +* ``allowed_methods`` and ``anon_allowed_methods`` are now defunct. if a method is defined, it's available. + The ``permissions`` attribute on a ``View`` is now used to provide generic permissions checking. + Use permission classes such as ``FullAnonAccess``, ``IsAuthenticated`` or ``IsUserOrIsAnonReadOnly`` to set the permissions. + +* The ``authenticators`` class becomes ``authentication``. Class names change to ``Authentication``. + +* The ``emitters`` class becomes ``renderers``. Class names change to ``Renderers``. + +* ``ResponseException`` becomes ``ErrorResponse``. + +* The mixin classes have been nicely refactored, the basic mixins are now ``RequestMixin``, ``ResponseMixin``, ``AuthMixin``, and ``ResourceMixin`` + You can reuse these mixin classes individually without using the ``View`` class. + +## 0.1.1 + +* Final build before pulling in all the refactoring changes for 0.2, in case anyone needs to hang on to 0.1. + +## 0.1.0 + +* Initial release. + diff --git a/docs/topics/credits.md b/docs/topics/credits.md index c20f5246..e54fd4bf 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -40,6 +40,7 @@ The following people have helped make REST framework great. * Can Yavuz - [tschan] * Shawn Lewis - [shawnlewis] * Alec Perkins - [alecperkins] +* Michael Barrett - [phobologic] Many thanks to everyone who's contributed to the project. @@ -102,3 +103,4 @@ To contact the author directly: [tschan]: https://github.com/tschan [shawnlewis]: https://github.com/shawnlewis [alecperkins]: https://github.com/alecperkins +[phobologic]: https://github.com/phobologic diff --git a/docs/topics/rest-hypermedia-hateoas.md b/docs/topics/rest-hypermedia-hateoas.md new file mode 100644 index 00000000..46a4c9d7 --- /dev/null +++ b/docs/topics/rest-hypermedia-hateoas.md @@ -0,0 +1,52 @@ +# REST, Hypermedia & HATEOAS + +> You keep using that word "REST". I do not think it means what you think it means. +> +> — Mike Amundsen, [REST fest 2012 keynote][cite]. + +First off, the disclaimer. The name "Django REST framework" was choosen with a view to making sure the project would be easily found by developers. Throughout the documentation we try to use the more simple and technically correct terminology of "Web APIs". + +If you are serious about designing a Hypermedia APIs, you should look to resources outside of this documentation to help inform your design choices. + +The following fall into the "required reading" category. + +* Fielding's dissertation - [Architectural Styles and +the Design of Network-based Software Architectures][dissertation]. +* Fielding's "[REST APIs must be hypertext-driven][hypertext-driven]" blog post. +* Leonard Richardson & Sam Ruby's [RESTful Web Services][restful-web-services]. +* Mike Amundsen's [Building Hypermedia APIs with HTML5 and Node][building-hypermedia-apis]. +* Steve Klabnik's [Designing Hypermedia APIs][designing-hypermedia-apis]. +* The [Richardson Maturity Model][maturitymodel]. + +For a more thorough background, check out Klabnik's [Hypermedia API reading list][readinglist]. + +# Building Hypermedia APIs with REST framework + +REST framework is an agnositic Web API toolkit. It does help guide you towards building well-connected APIs, and makes it easy to design appropriate media types, but it does not strictly enforce any particular design style. + +### What REST framework *does* provide. + +It is self evident that REST framework makes it possible to build Hypermedia APIs. The browseable API that it offers is built on HTML - the hypermedia language of the web. + +REST framework also includes [serialization] and [parser]/[renderer] components that make it easy to build appropriate media types, [hyperlinked relations][fields] for building well-connected systems, and great support for [content negotiation][conneg]. + +### What REST framework *doesn't* provide. + +What REST framework doesn't do is give you is machine readable hypermedia formats such as [Collection+JSON][collection] by default, or the ability to auto-magically create HATEOAS style APIs. Doing so would involve making opinionated choices about API design that should really remain outside of the framework's scope. + +[cite]: http://vimeo.com/channels/restfest/page:2 +[dissertation]: http://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm +[hypertext-driven]: http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven +[restful-web-services]: +[building-hypermedia-apis]: … +[designing-hypermedia-apis]: http://designinghypermediaapis.com/ +[restisover]: http://blog.steveklabnik.com/posts/2012-02-23-rest-is-over +[readinglist]: http://blog.steveklabnik.com/posts/2012-02-27-hypermedia-api-reading-list +[maturitymodel]: http://martinfowler.com/articles/richardsonMaturityModel.html + +[collection]: http://www.amundsen.com/media-types/collection/ +[serialization]: ../api-guide/serializers.md +[parser]: ../api-guide/parsers.md +[renderer]: ../api-guide/renderers.md +[fields]: ../api-guide/fields.md +[conneg]: ../api-guide/content-negotiation.md
\ No newline at end of file diff --git a/docs/tutorial/1-serialization.md b/docs/tutorial/1-serialization.md index 610d8ed1..cd4b7558 100644 --- a/docs/tutorial/1-serialization.md +++ b/docs/tutorial/1-serialization.md @@ -45,11 +45,11 @@ The simplest way to get up and running will probably be to use an `sqlite3` data } } -We'll also need to add our new `blog` app and the `djangorestframework` app to `INSTALLED_APPS`. +We'll also need to add our new `blog` app and the `rest_framework` app to `INSTALLED_APPS`. INSTALLED_APPS = ( ... - 'djangorestframework', + 'rest_framework', 'blog' ) @@ -67,7 +67,7 @@ For the purposes of this tutorial we're going to start by creating a simple `Com from django.db import models - class Comment(models.Model): + class Comment(models.Model): email = models.EmailField() content = models.CharField(max_length=200) created = models.DateTimeField(auto_now_add=True) @@ -81,10 +81,11 @@ Don't forget to sync the database for the first time. We're going to create a simple Web API that we can use to edit these comment objects with. The first thing we need is a way of serializing and deserializing the objects into representations such as `json`. We do this by declaring serializers, that work very similarly to Django's forms. Create a file in the project named `serializers.py` and add the following. from blog import models - from djangorestframework import serializers + from rest_framework import serializers class CommentSerializer(serializers.Serializer): + id = serializers.IntegerField(readonly=True) email = serializers.EmailField() content = serializers.CharField(max_length=200) created = serializers.DateTimeField() @@ -114,8 +115,8 @@ Okay, once we've got a few imports out of the way, we'd better create a few comm from blog.models import Comment from blog.serializers import CommentSerializer - from djangorestframework.renderers import JSONRenderer - from djangorestframework.parsers import JSONParser + from rest_framework.renderers import JSONRenderer + from rest_framework.parsers import JSONParser c1 = Comment(email='leila@example.com', content='nothing to say') c2 = Comment(email='tom@example.com', content='foo bar') @@ -128,13 +129,13 @@ We've now got a few comment instances to play with. Let's take a look at serial serializer = CommentSerializer(instance=c1) serializer.data - # {'email': u'leila@example.com', 'content': u'nothing to say', 'created': datetime.datetime(2012, 8, 22, 16, 20, 9, 822774, tzinfo=<UTC>)} + # {'id': 1, 'email': u'leila@example.com', 'content': u'nothing to say', 'created': datetime.datetime(2012, 8, 22, 16, 20, 9, 822774, tzinfo=<UTC>)} At this point we've translated the model instance into python native datatypes. To finalise the serialization process we render the data into `json`. stream = JSONRenderer().render(serializer.data) stream - # '{"email": "leila@example.com", "content": "nothing to say", "created": "2012-08-22T16:20:09.822"}' + # '{"id": 1, "email": "leila@example.com", "content": "nothing to say", "created": "2012-08-22T16:20:09.822"}' Deserialization is similar. First we parse a stream into python native datatypes... @@ -159,9 +160,10 @@ Edit the `blog/views.py` file, and add the following. from blog.models import Comment from blog.serializers import CommentSerializer - from djangorestframework.renderers import JSONRenderer - from djangorestframework.parsers import JSONParser from django.http import HttpResponse + from django.views.decorators.csrf import csrf_exempt + from rest_framework.renderers import JSONRenderer + from rest_framework.parsers import JSONParser class JSONResponse(HttpResponse): @@ -177,6 +179,7 @@ Edit the `blog/views.py` file, and add the following. The root of our API is going to be a view that supports listing all the existing comments, or creating a new comment. + @csrf_exempt def comment_root(request): """ List all comments, or create a new comment. @@ -194,10 +197,13 @@ The root of our API is going to be a view that supports listing all the existing comment.save() return JSONResponse(serializer.data, status=201) else: - return JSONResponse(serializer.error_data, status=400) + return JSONResponse(serializer.errors, status=400) + +Note that because we want to be able to POST to this view from clients that won't have a CSRF token we need to mark the view as `csrf_exempt`. This isn't something that you'd normally want to do, and REST framework views actually use more sensible behavior than this, but it'll do for our purposes right now. We'll also need a view which corrosponds to an individual comment, and can be used to retrieve, update or delete the comment. + @csrf_exempt def comment_instance(request, pk): """ Retrieve, update or delete a comment instance. @@ -219,7 +225,7 @@ We'll also need a view which corrosponds to an individual comment, and can be us comment.save() return JSONResponse(serializer.data) else: - return JSONResponse(serializer.error_data, status=400) + return JSONResponse(serializer.errors, status=400) elif request.method == 'DELETE': comment.delete() @@ -251,4 +257,4 @@ Our API views don't do anything particularly special at the moment, beyond serve We'll see how we can start to improve things in [part 2 of the tutorial][tut-2]. [virtualenv]: http://www.virtualenv.org/en/latest/index.html -[tut-2]: 2-requests-and-responses.md
\ No newline at end of file +[tut-2]: 2-requests-and-responses.md diff --git a/docs/tutorial/2-requests-and-responses.md b/docs/tutorial/2-requests-and-responses.md index 89f92c4b..d889b1e0 100644 --- a/docs/tutorial/2-requests-and-responses.md +++ b/docs/tutorial/2-requests-and-responses.md @@ -40,9 +40,9 @@ We don't need our `JSONResponse` class anymore, so go ahead and delete that. On from blog.models import Comment from blog.serializers import CommentSerializer - from djangorestframework import status - from djangorestframework.decorators import api_view - from djangorestframework.response import Response + from rest_framework import status + from rest_framework.decorators import api_view + from rest_framework.response import Response @api_view(['GET', 'POST']) def comment_root(request): @@ -61,7 +61,7 @@ We don't need our `JSONResponse` class anymore, so go ahead and delete that. On comment.save() return Response(serializer.data, status=status.HTTP_201_CREATED) else: - return Response(serializer.error_data, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) Our instance view is an improvement over the previous example. It's a little more concise, and the code now feels very similar to if we were working with the Forms API. We're also using named status codes, which makes the response meanings more obvious. @@ -87,7 +87,7 @@ Our instance view is an improvement over the previous example. It's a little mo comment.save() return Response(serializer.data) else: - return Response(serializer.error_data, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) elif request.method == 'DELETE': comment.delete() @@ -112,7 +112,7 @@ and Now update the `urls.py` file slightly, to append a set of `format_suffix_patterns` in addition to the existing URLs. from django.conf.urls import patterns, url - from djangorestframework.urlpatterns import format_suffix_patterns + from rest_framework.urlpatterns import format_suffix_patterns urlpatterns = patterns('blogpost.views', url(r'^$', 'comment_root'), @@ -125,21 +125,27 @@ We don't necessarily need to add these extra url patterns in, but it gives us a ## How's it looking? -Go ahead and test the API from the command line, as we did in [tutorial part 1][2]. Everything is working pretty similarly, although we've got some nicer error handling if we send invalid requests. +Go ahead and test the API from the command line, as we did in [tutorial part 1][tut-1]. Everything is working pretty similarly, although we've got some nicer error handling if we send invalid requests. **TODO: Describe using accept headers, content-type headers, and format suffixed URLs** -Now go and open the API in a web browser, by visiting [http://127.0.0.1:8000/][3]." +Now go and open the API in a web browser, by visiting [http://127.0.0.1:8000/][devserver]." **Note: Right now the Browseable API only works with the CBV's. Need to fix that.** -**TODO: Describe browseable API awesomeness** +### Browsability + +Because the API chooses a return format based on what the client asks for, it will, by default, return an HTML-formatted representation of the resource when that resource is requested by a browser. This allows for the API to be easily browsable and usable by humans. + +See the [browsable api][browseable-api] topic for more information about the browsable API feature and how to customize it. + ## What's next? -In [tutorial part 3][4], we'll start using class based views, and see how generic views reduce the amount of code we need to write. +In [tutorial part 3][tut-3], we'll start using class based views, and see how generic views reduce the amount of code we need to write. [json-url]: http://example.com/api/items/4.json -[2]: 1-serialization.md -[3]: http://127.0.0.1:8000/ -[4]: 3-class-based-views.md
\ No newline at end of file +[devserver]: http://127.0.0.1:8000/ +[browseable-api]: ../topics/browsable-api.md +[tut-1]: 1-serialization.md +[tut-3]: 3-class-based-views.md diff --git a/docs/tutorial/3-class-based-views.md b/docs/tutorial/3-class-based-views.md index 24785179..25d5773f 100644 --- a/docs/tutorial/3-class-based-views.md +++ b/docs/tutorial/3-class-based-views.md @@ -1,6 +1,6 @@ # Tutorial 3: Class Based Views -We can also write our API views using class based views, rather than function based views. As we'll see this is a powerful pattern that allows us to reuse common functionality, and helps us keep our code [DRY][1]. +We can also write our API views using class based views, rather than function based views. As we'll see this is a powerful pattern that allows us to reuse common functionality, and helps us keep our code [DRY][dry]. ## Rewriting our API using class based views @@ -9,9 +9,9 @@ We'll start by rewriting the root view as a class based view. All this involves from blog.models import Comment from blog.serializers import CommentSerializer from django.http import Http404 - from djangorestframework.views import APIView - from djangorestframework.response import Response - from djangorestframework import status + from rest_framework.views import APIView + from rest_framework.response import Response + from rest_framework import status class CommentRoot(APIView): @@ -31,8 +31,6 @@ We'll start by rewriting the root view as a class based view. All this involves return Response(serializer.serialized, status=status.HTTP_201_CREATED) return Response(serializer.serialized_errors, status=status.HTTP_400_BAD_REQUEST) - comment_root = CommentRoot.as_view() - So far, so good. It looks pretty similar to the previous case, but we've got better seperation between the different HTTP methods. We'll also need to update the instance view. class CommentInstance(APIView): @@ -55,19 +53,31 @@ So far, so good. It looks pretty similar to the previous case, but we've got be comment = self.get_object(pk) serializer = CommentSerializer(request.DATA, instance=comment) if serializer.is_valid(): - comment = serializer.deserialized + comment = serializer.object comment.save() return Response(serializer.data) - return Response(serializer.error_data, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, pk, format=None): comment = self.get_object(pk) comment.delete() return Response(status=status.HTTP_204_NO_CONTENT) - comment_instance = CommentInstance.as_view() - That's looking good. Again, it's still pretty similar to the function based view right now. + +We'll also need to refactor our URLconf slightly now we're using class based views. + + from django.conf.urls import patterns, url + from rest_framework.urlpatterns import format_suffix_patterns + from blogpost import views + + urlpatterns = patterns('', + url(r'^$', views.CommentRoot.as_view()), + url(r'^(?P<pk>[0-9]+)$', views.CommentInstance.as_view()) + ) + + urlpatterns = format_suffix_patterns(urlpatterns) + Okay, we're done. If you run the development server everything should be working just as before. ## Using mixins @@ -80,8 +90,8 @@ Let's take a look at how we can compose our views by using the mixin classes. from blog.models import Comment from blog.serializers import CommentSerializer - from djangorestframework import mixins - from djangorestframework import generics + from rest_framework import mixins + from rest_framework import generics class CommentRoot(mixins.ListModelMixin, mixins.CreateModelMixin, @@ -95,8 +105,6 @@ Let's take a look at how we can compose our views by using the mixin classes. def post(self, request, *args, **kwargs): return self.create(request, *args, **kwargs) - comment_root = CommentRoot.as_view() - We'll take a moment to examine exactly what's happening here - We're building our view using `MultipleObjectBaseView`, and adding in `ListModelMixin` and `CreateModelMixin`. The base class provides the core functionality, and the mixin classes provide the `.list()` and `.create()` actions. We're then explictly binding the `get` and `post` methods to the appropriate actions. Simple enough stuff so far. @@ -117,8 +125,6 @@ The base class provides the core functionality, and the mixin classes provide th def delete(self, request, *args, **kwargs): return self.destroy(request, *args, **kwargs) - comment_instance = CommentInstance.as_view() - Pretty similar. This time we're using the `SingleObjectBaseView` class to provide the core functionality, and adding in mixins to provide the `.retrieve()`, `.update()` and `.destroy()` actions. ## Using generic class based views @@ -127,26 +133,21 @@ Using the mixin classes we've rewritten the views to use slightly less code than from blog.models import Comment from blog.serializers import CommentSerializer - from djangorestframework import generics + from rest_framework import generics class CommentRoot(generics.RootAPIView): model = Comment serializer_class = CommentSerializer - comment_root = CommentRoot.as_view() - class CommentInstance(generics.InstanceAPIView): model = Comment serializer_class = CommentSerializer - comment_instance = CommentInstance.as_view() - - Wow, that's pretty concise. We've got a huge amount for free, and our code looks like good, clean, idomatic Django. -Next we'll move onto [part 4 of the tutorial][2], where we'll take a look at how we can customize the behavior of our views to support a range of authentication, permissions, throttling and other aspects. +Next we'll move onto [part 4 of the tutorial][tut-4], where we'll take a look at how we can customize the behavior of our views to support a range of authentication, permissions, throttling and other aspects. -[1]: http://en.wikipedia.org/wiki/Don't_repeat_yourself -[2]: 4-authentication-permissions-and-throttling.md +[dry]: http://en.wikipedia.org/wiki/Don't_repeat_yourself +[tut-4]: 4-authentication-permissions-and-throttling.md diff --git a/docs/tutorial/4-authentication-permissions-and-throttling.md b/docs/tutorial/4-authentication-permissions-and-throttling.md index 5c37ae13..c8d7cbd3 100644 --- a/docs/tutorial/4-authentication-permissions-and-throttling.md +++ b/docs/tutorial/4-authentication-permissions-and-throttling.md @@ -1,3 +1,5 @@ -[part 5][5] +# Tutorial 4: Authentication & Permissions -[5]: 5-relationships-and-hyperlinked-apis.md
\ No newline at end of file +Nothing to see here. Onwards to [part 5][tut-5]. + +[tut-5]: 5-relationships-and-hyperlinked-apis.md
\ No newline at end of file diff --git a/docs/tutorial/5-relationships-and-hyperlinked-apis.md b/docs/tutorial/5-relationships-and-hyperlinked-apis.md index 3d9598d7..a76f81e8 100644 --- a/docs/tutorial/5-relationships-and-hyperlinked-apis.md +++ b/docs/tutorial/5-relationships-and-hyperlinked-apis.md @@ -1,9 +1,11 @@ +# Tutorial 5 - Relationships & Hyperlinked APIs + **TODO** * Create BlogPost model * Demonstrate nested relationships * Demonstrate and describe hyperlinked relationships -[part 6][1] +Onwards to [part 6][tut-6]. -[1]: 6-resource-orientated-projects.md +[tut-6]: 6-resource-orientated-projects.md diff --git a/docs/tutorial/6-resource-orientated-projects.md b/docs/tutorial/6-resource-orientated-projects.md index 4282c25d..3c3e7fed 100644 --- a/docs/tutorial/6-resource-orientated-projects.md +++ b/docs/tutorial/6-resource-orientated-projects.md @@ -1,31 +1,56 @@ -serializers.py +# Tutorial 6 - Resources - class BlogPostSerializer(URLModelSerializer): - class Meta: - model = BlogPost +Resource classes are just View classes that don't have any handler methods bound to them. The actions on a resource are defined, - class CommentSerializer(URLModelSerializer): - class Meta: - model = Comment +This allows us to: + +* Encapsulate common behaviour accross a class of views, in a single Resource class. +* Seperate out the actions of a Resource from the specfics of how those actions should be bound to a particular set of URLs. + +## Refactoring to use Resources, not Views + +For instance, we can re-write our 4 sets of views into something more compact... resources.py class BlogPostResource(ModelResource): serializer_class = BlogPostSerializer model = BlogPost - permissions = [AdminOrAnonReadonly()] - throttles = [AnonThrottle(rate='5/min')] + permissions_classes = (permissions.IsAuthenticatedOrReadOnly,) + throttle_classes = (throttles.UserRateThrottle,) class CommentResource(ModelResource): serializer_class = CommentSerializer model = Comment - permissions = [AdminOrAnonReadonly()] - throttles = [AnonThrottle(rate='5/min')] + permissions_classes = (permissions.IsAuthenticatedOrReadOnly,) + throttle_classes = (throttles.UserRateThrottle,) + +## Binding Resources to URLs explicitly +The handler methods only get bound to the actions when we define the URLConf. Here's our urls.py: -Now that we're using Resources rather than Views, we don't need to design the urlconf ourselves. The conventions for wiring up resources into views and urls are handled automatically. All we need to do is register the appropriate resources with a router, and let it do the rest. Here's our re-wired `urls.py` file. + comment_root = CommentResource.as_view(actions={ + 'get': 'list', + 'post': 'create' + }) + comment_instance = CommentInstance.as_view(actions={ + 'get': 'retrieve', + 'put': 'update', + 'delete': 'destroy' + }) + ... # And for blog post + + urlpatterns = patterns('blogpost.views', + url(r'^$', comment_root), + url(r'^(?P<pk>[0-9]+)$', comment_instance) + ... # And for blog post + ) + +## Using Routers + +Right now that hasn't really saved us a lot of code. However, now that we're using Resources rather than Views, we actually don't need to design the urlconf ourselves. The conventions for wiring up resources into views and urls can be handled automatically, using `Router` classes. All we need to do is register the appropriate resources with a router, and let it do the rest. Here's our re-wired `urls.py` file. from blog import resources - from djangorestframework.routers import DefaultRouter + from rest_framework.routers import DefaultRouter router = DefaultRouter() router.register(resources.BlogPostResource) @@ -44,6 +69,8 @@ We've reached the end of our tutorial. If you want to get more involved in the * Contribute on GitHub by reviewing issues, and submitting issues or pull requests. * Join the REST framework group, and help build the community. -* Follow me [on Twitter](https://twitter.com/_tomchristie) and say hi. +* Follow me [on Twitter][twitter] and say hi. + +**Now go build some awesome things.** -Now go build something great.
\ No newline at end of file +[twitter]: https://twitter.com/_tomchristie |
