Customising the Generated API¶
Configuration Options¶
Configuration options are managed by pyramid_settings_wrapper.Settings
.
This provides default values as class attributes.
Configuration Options
These options can be overridden in the pyramid app ini-file.
# Allow client to specify resource ids.
allow_client_ids = False
# API version for prefixing endpoints and metadata generation.
api_version =
# Whether or not to add debugging endpoints.
debug_endpoints = False
# Whether or not to add debug information to the meta key in returned JSON.
debug_meta = False
# Module responsible for populating test data.
debug_test_data_module = test_data
# Whether or not to add a stack traceback to errors.
debug_traceback = False
# Expose foreign key fields in JSON.
expose_foreign_keys = False
# True = return information in meta about authz failures; False = pretend items don't exist
inform_of_get_authz_failures = True
# Should /metadata endpoint be enabled?
metadata_endpoints = True
# Modules to load to provide metadata endpoints (defaults are modules provided in the metadata package).
metadata_modules = JSONSchema OpenAPI
# File containing OpenAPI data (YAML or JSON)
openapi_file =
# Default pagination limit for collections.
paging_default_limit = 10
# Default limit on the number of items returned for collections.
paging_max_limit = 100
# Prefix for pyramid route names for view_classes.
route_name_prefix = pyramid_jsonapi
# Separator for pyramid route names.
route_name_sep = :
# Prefix for api endpoints (if metadata_endpoints is enabled).
route_pattern_api_prefix = api
# Prefix for metadata endpoints (if metadata_endpoints is enabled).
route_pattern_metadata_prefix = metadata
# "Parent" prefix for all endpoints
route_pattern_prefix =
# Separator for pyramid route patterns.
route_pattern_sep = /
# File containing jsonschema JSON for validation.
schema_file =
# jsonschema schema validation enabled?
schema_validation = True
# Module implementing the collection_get workflow.
workflow_collection_get = pyramid_jsonapi.workflow.loop.collection_get
# Module implementing the collection_post workflow.
workflow_collection_post = pyramid_jsonapi.workflow.loop.collection_post
# Module implementing the item_delete workflow.
workflow_item_delete = pyramid_jsonapi.workflow.loop.item_delete
# Module implementing the item_get workflow.
workflow_item_get = pyramid_jsonapi.workflow.loop.item_get
# Module implementing the item_patch workflow.
workflow_item_patch = pyramid_jsonapi.workflow.loop.item_patch
# Module implementing the related_get workflow.
workflow_related_get = pyramid_jsonapi.workflow.loop.related_get
# Module implementing the relationships_delete workflow.
workflow_relationships_delete = pyramid_jsonapi.workflow.loop.relationships_delete
# Module implementing the relationships_get workflow.
workflow_relationships_get = pyramid_jsonapi.workflow.loop.relationships_get
# Module implementing the relationships_patch workflow.
workflow_relationships_patch = pyramid_jsonapi.workflow.loop.relationships_patch
# Module implementing the relationships_post workflow.
workflow_relationships_post = pyramid_jsonapi.workflow.loop.relationships_post
Model Class Options¶
The behaviour of classes (and, by extension, collections) is controlled via a special
class attribute, __pyramid_jsonapi__
. The value of this attribute should be
a dictionary with each key representing an option name and each value
representing the option value. For example, the following will create a
Person
class with a table name people
in the database but a collection
name humans
in the resulting API:
class Person(Base):
__tablename__ = 'people'
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(Text)
__pyramid_jsonapi__ = {
'collection_name': 'humans'
}
The available options are:
Option |
Value Type |
Description |
---|---|---|
collection_name |
string |
Name of collection in the API. |
id_col_name |
string |
Used internally to track id column - do not use. |
Model Column Options¶
Some behaviours can be controlled on a column by column basis. SQLAlchemy uses
the special column attribute info
to carry information (as a dictionary)
from third party modules (like pyramid_jsonapi). The pyramid_jsonapi module
expects column options as a dictionary stored in the pyramid_jsonapi
key of
the info
dictionary. For example, to make a column called
invisible_column
invisible to the API:
class Person(Base):
__tablename__ = 'people'
id = Column(BigInteger, primary_key=True, autoincrement=True)
invisible_column = Column(Text)
invisible_column.info.update({'pyramid_jsonapi': {'visible': False}})
Available column options:
Option |
Value Type |
Description |
---|---|---|
visible |
Boolean |
Whether or not to display this column in the API. |
Model Relationship Options¶
The same info
attribute used to specify column options above can be used to
specify relationship options. For example, to make a relationship called
invisible_comments
invisible to the API:
class Person(Base):
__tablename__ = 'people'
id = Column(BigInteger, primary_key=True, autoincrement=True)
invisible_comments = relationship('Comment')
invisible_comments.info.update({'pyramid_jsonapi': {'visible': False}})
Available relationship options:
Option |
Value Type |
Description |
---|---|---|
visible |
Boolean |
Whether to display this relationship in the API. |
Selectively Passing Models for API Generation¶
Your database may have some tables which you do not wish to expose as collections in the generated API. You can be selective by:
writing a models module with only the model classes you wish to expose; or
passing an iterable of only the model classes you wish to expose to
pyramid_jsonapi.PyramidJSONAPI()
.
URL Paths¶
There are a number of prefix configuration options that can be used to customise the URL path used in the generated API. These are useful for mixing the API with other pages, adding API versioning etc.
The path is constructed as follows - omitting any variables which are unset. The separator between fields is route_pattern_sep - shown here as the default ‘/’. type is one of either api or metadata.
`
/route_pattern_prefix/api_version/route_pattern_<type>_prefix/endpoint
`
These options and their defaults are documented above in Configuration Options.
Modifying Endpoints¶
Endpoints are created automatically from a dictionary: api_object.endpoint_data.endpoints
.
This takes the following format:
{
'query_parameters': {
'fields': '',
'filter': '',
'page': ['limit', 'offset'],
'sort': '',
},
'responses': {HTTPOK: {'reason': ['A server MUST respond to a successful request to fetch an individual resource or resource collection with a 200 OK response.']}},
'endpoints': {
'item': {
'request_schema': False,
'route_pattern': '{'fields': ['id'], 'pattern': '{{{}}}'}',
'http_methods': {
'DELETE': {
'function': 'delete',
'responses'': { HTTPOk: {'reason': ['A server MUST return a 200 OK status code if a deletion request is successful']}},
},
'GET': {
'function': 'get',
},
'PATCH': {
'function': 'patch',
},
},
},
... # other endpoints omitted
}
}
The endpoints
and methods
are the parts you are most likely to want to modify.
There are 4
endpoints
defined:collection
,item
,relationships
andrelated
.Each
endpoint
may haveroute_pattern
defined. This is a list of fields, and the format string used to join them. ({sep}
will be replaced withroute_name_sep
)Each
endpoint
may have 0 or morehttp_methods
defined. (GET
,POST
, etc).Each
endpoint
may haveresponses
defined. This is a dictionary ofpyramid.httpexceptions
keys, the value is a dict withreason
containing list of reasons for returning this response.request_schema
defines whether or not this endpoint expects a request body (for jsonschema generation/validation).Each
method
must havefunction
defined. This is the name (string) of the view function to call for this endpoint.Each
method
may have arenderer
defined (if omitted, this defaults to'json'
).
Additionally, the following keys are provided (though are less likely to be modified).
query_parameters
defines the http query parameters that endpoints expect.responses
defines the various http responses (keyed bypyramid.httpexceptions
objects ) that may be returned, and the reason(s) why.responses
are used in the code to validate responses, and provide schema information.responses can be defined at a ‘global’, endpoint, or method level, and will be merged together as appropriate.
you may wish to modify
responses
if your app wishes to return statuses outside of the schema, to prevent them being flagged as errors.
For example, to extend this structure to handle the OPTIONS
http_method
for all endpoints (e.g. for CORS):
...
# Create a view class method.
def options_view(self):
return ''
# Instantiate the class
pj = pyramid_jsonapi.PyramidJSONAPI(config, models, dbsession)
# Update all endpoints to handle OPTIONS http_method requests
for endpoint in pj.EndpointData.endpoints:
pj.EndpointData.endpoints[endpoint]['http_methods']['OPTIONS'] = {'function': 'options_view',
'renderer': 'string'}
# Create the view_classes
pj.create_jsonapi()
# Bind the custom options method (defined above) to each view_class
for vc in pj.view_classes.values():
vc.options_view = options_view
Search (Filter) Operators¶
Search filters are on collection get operations are specified with URL paramaters of the form filter[attribute:op]=value. A number of search/filter operators are supported out of the box. The list currently includes the following for all column types:
eq
ne
startswith
endswith
contains
lt
gt
le
ge
like
orilike
. Note that both of these use ‘*’ in place of ‘%’ to avoid much URL escaping.
plus these for JSONB columns:
contains
contained_by
has_all
has_any
has_key
You can add support for new filters using the PyramidJSONAPI.filter_registry
(which is an instance of FilterRegistry
):
pj_api.filter_registry.register('my_comparator')
The above would register the sqlalchemy column comparator my_comparator
(which should exist as a valid sqlalchemy comparator function) as valid for all column types and also create a URL filter op called my_comparator
. Any instances of __
(double underscore) are stripped from the comparator name to create the filter name, so if we had called the comparator __my_comparator__
it would still become the filter operator my_comparator
. For example, the sqlalachemy comparator __eq__
is registered with:
pj_api.filter_registry.register('__eq__')
But has a filter name of eq
.
You can override the autogenerated name by providing one as an argument:
pj_api.filter_registry.register('my_comparator', filter_name='my_filter')
The comparator/filter combination is valid for all column types by default, which is the same as specifying:
pj_api.filter_registry.register('my_comparator', column_type='__ALL__')
Comparators can be registered as valid for individual column types by passing a column type:
from sqlalchemy.dialects.postgresql import JSONB
pj_api.filter_registry.register('my_comparator', column_type=JSONB)
It’s also possible to specify a value transformation function to change the paramter value before it is passed to the comparator. For example the like
filter swaps all ‘*’ characters for ‘%’ before calling the associated like
comparator. It is registered like this:
pj_api.filter_registry.register(
'like',
value_transform=lambda val: re.sub(r'\*', '%', val)
)
Stages and the Workflow¶
pyramid_jsonapi
services requests in stages. These stages are sequences of
functions implemented as a collections.deque
for each stage on each
method of each view class. It is possible to add (or remove) functions to those
deques directly but it is recommended that you use the following utility
function instead:
view_class.add_stage_handler(
['get', 'collection_get'], ['alter_document'], hfunc,
add_after='end', # 'end' is the default
add_existing=False, # False is the default
)
will append hfunc
to the deque for the alter_document
stage of
view_class
’s methods get
and collection_get
. add_after
can be
'end'
to append to the deque, 'start'
to appendleft, or an existing
handler in the deque to insert after it. add_existing
is a boolean
determining whether the handler should be added to the deque even if it exists
there already.
To register a handler for all of the view methods involved in servicing a
particular http method, use pj.endpoint_data.http_to_view_methods
:
view_class.add_stage_handler(
api_instance.endpoint_data.http_to_view_methods['post'],
['alter_request'],
hfunc,
add_after='end', # 'end' is the default
add_existing=False, # False is the default
)
The above would append hfunc
to the stage alter_request
for all of the
view methods associated with the http method post
(collection_post
,
relationships_post
).
If you do want to get directly at a stage deque, you can get it with something like:
ar_stage = pj.view_classes[models.Person].collection_post.stages['alter_request']
The handler functions in each stage deque will be called in order at the appropriate point and should have the following signature:
def handler_function(argument, view_instance, stage, view_method):
# some function definition...
return same_type_of_thing_as_argument
argument
in the alter_request
stage would be a request, for example,
while in alter_document
it would be a document object. argument
and
view_instance
are passed positionally while stage
and view_method
are keyword arguments. Handlers in a stage deque should work as a pipeline so
it is important that you return the (potentially altered or replaced
argument
)
For example, let’s say you would like to alter all posts to the people collection so that a created_on_server attribute is populated automatically.
import socket
def sh_created_on_server(req, view, **kwargs):
obj_data = req.json_body['data']
obj_data['attributes']['created_on_server'] = socket.gethostname()
req.body = json.dumps({'data': obj_data}).encode()
return req
pj.view_classes[models.Person].add_stage_handler(
['collection_post'], ['alter_request'],
)
The stages are run in the following order:
alter_request
. Functions in this stage alter the request. For example possibly editing any POST or PATCH such that it contains a server defined calculated attribute.validate_request
. Functions in this stage validate the request. For example, ensuring that the correct headers are set and that any json validates against the schema.Any stages defined by a
workflow
function from a loadable workflow module.alter_document
. Functions in this stage alter thedocument
, which is to say the dictionary which will be JSON serialised and sent back in the response.validate_response
.
The Loop Workflow¶
The default workflow is the loop
workflow. It defines the following stages:
alter_query
. Alter thesqlalchemy.orm.query.Query
which will be executed (using.all()
or.one()
) to fetch the primary result(s).alter_related_query
. Alter thesqlalchemy.orm.query.Query
which will be executed to fetch related result(s).alter_result
. Alter aworkflow.ResultObject
object containing a database result (a sqlalchemy orm object) from a query of the requested collection. This might also involve rejecting the whole object (for example, for authorisation purposes).before_write_item
. Alter a sqlalchemy orm item before it is written (flushed) to the database.
Authorisation at the Object Level¶
Authorisation of access a the object level can quite complicated in typical
JSONAPI apis. The complexity arises from the connecting nature of relationships.
Every operation on an object with relationships implies other operations on any
related objects. The simplest example is GET
: get
permission is required
on any object directly fetched and also on any related object fetched. More
complicated is any write based operation. For example, to update the owner of a
blog, you need patch
permission on blog_x.owner
, post
permission on
new_owner.blogs
(to add blog_x
to the reverse relationship) and
delete
permission on old_owner.blogs
(to remove blog_x
from the
reverse relationship).
There are stage handlers available for stages which handle most of the logic of
authorisation. At the moment these are implemented for alter_result
for read
operations and alter_request
for write operations. Other stages might be
supported in the future.
The remaining logic is provided by permission filters which you provide. The job of a permission filter is to decide, for an individual object, whether the specified operation is allowed on that object or not. The permission handler built in to pyramid_jsonapi will call the permission filters at the appropriate times (including for related objects) and then stitch the answers back together into a coherent, authorised whole.
The default permission filters allow everything, which is the same as not having
any permission handlers at all. Permission filters should be registered with
CollectionView.register_permission_filter()
:
from pyramid_jsonapi.permissions import Targets
view_class.register_permission_filter(
permissions,
stages,
pfunc,
target_types=list(Targets) # the default is all target types.
)
permissions
is an iterable of permissions (as strings) that this filter will
handle. Permissions are equivalent to http verbs (lowercased) - ‘get’, ‘post’,
‘patch’, ‘delete’. You can also use any ‘permission set’ defined in
pj.endpoint_data.endpoints['http_method_sets']
. At time of writing these are
‘read’ (equivalent to ‘get’), ‘write’ (‘post’, ‘patch’ and ‘delete’), and ‘all’.
stages
is an iterable of stage names.
pfunc
is the function to handle permission requests.
target_types
is an iterable of target types that this filter will handle.
Target types are a hint to permission filters specifying the type of target
being authorised in the current call. They are one of the
pyramid_jsonapi.permissions.Targets
enumeration, which at time of writing
includes Targets.collection
for collection based operations where an id
might not be included, Targets.item
for operations directly on an item
and its attributes, and Targets.relationship
for operations on
relationships.
Permission filters will be called from within the code like this:
your_filter(
object_rep,
view=view_instance,
stage=stage_name,
permission=permission_sought,
target=PermissionTarget_object,
mask=Permission_object,
)
object_rep
is some representation of the object to be authorised. Different
stages imply different representations. For example the alter_request
stage
will pass a dictionary representing an item from data
from the JSON
contained in a pyramid request while the alter_result
stage from the loop
workflow will pass a workflow.ResultObject
, which is a wrapper around a
sqlAlchemy ORM object (which you can get to as object_rep.object
).
view
is the current view instance.
stage
is the name of the current stage.
permission
is one of get
, post
, patch
, or delete
.
target
is a pyramid_jsonapi.permission.PermissionTarget
object.
target.type
will tell you whether the current authorisation request is for
a collection, item, or relationship and target.name
will tell you the
collection or relationship name.
TODO mask
is a work in progress. When implemented it will specify which
attributes and relationships the permission filter should address. Any others
might not be represented in object_rep
even though an object of that class
would usually have them. This is mainly to handle the case where the
request specifies a sparse field set.
Note that you can get the current sqlAlchemy session from
view.dbsession
(which you might need to make the queries required
for authorisation) and the pyramid request from view.request
which
should give you access to the usual things.
The simplest thing that a permission filter can do is return True
(permission
is granted for the whole object) or False
(permission
is denied for the whole object). To control permissions
for attributes or relationships, you must return a
pyramid_jsonapi.permissions.Permission
object. You can create one
appropriate to the current view with:
permission_object = view.permission_object(
attributes=view.permission_template.attributes, # Set of allowed attribute
# names. Defaults to all
# attributes.
relationships=view.permission_template.relationships, # Set of allowed rel
# names.
id=True,
subtract_attributes=frozenset(), # Set of attributes to subtract.
subtract_relationships=frozenset() # Set of relationships to subtract.
)
or you can construct one from scratch with:
from pyramid_jsonapi.permissions import Permission
Permission(
template, # A template governing which attributes and relationships are
# available. You should normally use view.permission_template.
attributes=template.attributes, # The set of allowed attribute names. The
# default is all attributes in the template.
relationships=template.relationships, # The set of allowed rel names.
id=True, # Controls visibility of / action on the whole object.
# Most of the time this should be True, which is the default.
)
Permission objects are immutable.
The different target types are worth saying a little more about since they affect how your permission filter is called.
target.type
collection means that the operation is against a collection (almost certainly a GET or a POST).target.name
will hold the name of the collection. Theid
ofobject_rep
might not be defined (it commonly won’t be for POSTs). The handler calling the permission filter will not be concerned with relationship authorisation and will ignore relationships specified in anyPermission
object returned.
target.type
item means that the operation is against an item.target.name
will be empty (None
). Theid
ofobject_rep
should reliably be present. The handler calling the permission filter will not be concerned with relationship authorisation and will ignore relationships specified in anyPermission
object returned.
target.type
relationship means that the operation is against a relationship.target.name
will hold the name of the relationship. Theid
ofobject_rep
should reliably be present. The permission filter will be called once for each relationship to be authorised and for each item in a to_many operation (once for each item posted to a to_many operation, for example). The handler calling the permission filter will only look at whether or not the relationship in question is in the returnedPermission
object (it will be if you just returnTrue
and won’t if you returnFalse
).
Putting that together in some examples:
Let’s say you have banned the user ‘baddy’ and want to authorise GET requests so
that baddy can no longer fetch blogs. Both the alter_document
and
alter_result
stages would make sense as places to influence what will
be returned by a GET. We will choose alter_result
here so that we are
authorising results as soon as
they come from the database. You might have something like this in
__init__.py
:
pj = pyramid_jsonapi.PyramidJSONAPI(config, models)
pj.view_classes[models.Blogs].register_permission_filter(
['get'],
['alter_result'],
lambda obj, view, **kwargs: view.request.remote_user != 'baddy',
)
Next, you want to do authorisation on PATCH requests and allow only the author
of a blog post to PATCH it. The alter_request
stage is the most obvious
place to do this (you want to alter the request before it is turned into a
database update). You might do something like this in __init__.py
:
pj = pyramid_jsonapi.PyramidJSONAPI(config, models)
def patch_posts_filter(data, view, **kwargs):
post_obj = view.db_session.get(models.Posts, data['id']) # sqlalchemy 1.4+
# post_obj = view.db_session.query(models.Posts).get(data['id']) # sqlalchemy < 1.4
return view.request.remote_user == post_obj.author.name
pj.view_classes[models.Posts].register_permission_filter(
['patch'],
['alter_request'],
patch_posts_filter
)
Imagine that Person
objects have an age
attribute. Access to age
is
sensitive so only the person themselves and anyone in the (externally defined)
age_viewers
group should be able to see that attribute. Other viewers should
still be able to see the object so we can’t just return False
from the
permission filter - we must use the fuller return format.
pj = pyramid_jsonapi.PyramidJSONAPI(config, models)
def get_person_filter(person, view, **kwargs):
# This could be done in one 'if' but we split it out here for clarity.
#
# A person should see the full object for themselves.
if view.request.remote_user == person.username:
return True
#
# Anyone in the age_viewers group should also see the full object.
# get_group_members() is an imagined function in this app which gets the
# members of a named group.
if view.request.remote_user in get_group_members('age_viewers'):
return True
# Everyone else isn't allowed to see age.
return Permission.subtractive(
# subtractive constructs an object by subtracting sets.
view.permission_template,
{'age'} # 'age' will be removed from the set of attributes.
)
pj.view_classes[models.Person].register_permission_filter(
['get'],
['alter_result'],
get_person_filter
)