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 and related.

  • Each endpoint may have route_pattern defined. This is a list of fields, and the format string used to join them. ({sep} will be replaced with route_name_sep)

  • Each endpoint may have 0 or more http_methods defined. (GET, POST, etc).

  • Each endpoint may have responses defined. This is a dictionary of pyramid.httpexceptions keys, the value is a dict with reason 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 have function defined. This is the name (string) of the view function to call for this endpoint.

  • Each method may have a renderer 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 by pyramid.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 or ilike. 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 the document, 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 the sqlalchemy.orm.query.Query which will be executed (using .all() or .one()) to fetch the primary result(s).

  • alter_related_query. Alter the sqlalchemy.orm.query.Query which will be executed to fetch related result(s).

  • alter_result. Alter a workflow.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. The id of object_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 any Permission object returned.

  • target.type item means that the operation is against an item. target.name will be empty (None). The id of object_rep should reliably be present. The handler calling the permission filter will not be concerned with relationship authorisation and will ignore relationships specified in any Permission object returned.

  • target.type relationship means that the operation is against a relationship. target.name will hold the name of the relationship. The id of object_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 returned Permission object (it will be if you just return True and won’t if you return False).

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
)

What Happens With Authorisation Failures