HCMS GraphQL Support
Explains the GraphQL interface of the Headless CMS
The Headless CMS provides a read only GraphQL interface to entity data.
GraphQL
GraphQL uses its own schema to define data structures and available queries.
Headless CMS does not use this schema directly and does not allow user to specify it.
Schema is, instead, automatically created from JSON schemas of all entities.
In the case there are no schemas available yet, there is no way to produce valid GraphQL schema (specification forbids empty objects) and any attempt to use graphql interface fails with a special HTTP error code 422.
Current schema can be always obtained in human-readable form on special endpoint /graphql/schema.graphql.
Machine-friendly form is available via standard introspection mechanism
(part of the official GraphQL specification).
Note that the graphql schema changed in some versions (notably in v4.0) and old versions are still available depending on the full request path. This means that /hcms/v3.0/graphql/schema.graphql (compatability version) differs from /hcms/v4.0/graphql/schema.graphql (the new version)!
The differences, however, are mostly at the top level; the principle of schema conversion remains the same.
Conversion of names to identifier
GraphQL's languages (both query language and schema language) are very strict about identifiers: only ASCII letters, underscore and digits are allowed and identifier cannot start with digit.
JSON schema identifiers (property names) are, on the other hand, very lenient and often cannot be directly used - they have to be sanitized by following rules:
- All unsupported characters are replaced by _ (underscore).
- If the identifier starts with digit, _ (underscore) is prepended to it.
- This identifier can be used, if it is unique in its context.
- Property (field) names must be unique just in the context of its single type (object).
- Type names must be globally unique.
- In case of conflict, four-digit hexadecimal number (with uppercase letters) is appended.
- Lowest free value is used, starting with 0001.
- If the identifier already contains valid hexadecimal number ([A-F0-9]+) at the end, it is replaced.
Note that this might actually remove part of proper name, as long as it is all uppercase! - If there are too many duplicates, conversion fails. 65535 should be enough for everyone.
Entity schema conversion
Each entity schema is represented as single GraphQL type. Name of this type is generally the same as the schema name, converted by the same rule as other identifiers, sanitized by the process described above. Conflicts should be very rare (because schema names are already quite limited), but they can happen. To achieve consistent and predictable behavior, sanitation is always done in alphabetical order of the original schema names.
Please note that since the limitations of schema names have been relaxed in version 4.0, it is now much easier to get conflicts. For example, both article+content and article.content are the same in GraphQL (article_content) and thus create conflict.
Entity schemas usually contain properties of type object and they can also contain another objects, etc - depth is actually not limited by any artificial limit.
In GraphQL, each such object sub-schema at any level must be represented by top-level object type with unique name.
This name is created from full "path" to the sub-schema (schema name and list of all property names in path), concatenated together with _ used as separator.
This concatenated path is then sanitized by the process described above; conflicts are quite common in this case.
Sub-schemas are always sanitized after top-level schema names. This ensures that in case of conflict, it is some sub-schema type (which probably will not be ever directly used) that gets the numbered suffix, not the top-level schema name (which might be occasionally used in queries).
Included schemas (via $ref) are not handled by the schema conversion - they are treated just as a plain source include.
See later how to achieve full graph connection.
Example (standard schemas):
- article_content is converted to type article_content
- article schema is represented by type named article. Its field content is of type article_content0001 (which is not the same as the article_content).
type article {
content: article_content0001
}
Enum types
Scalar properties with defined enum list of possible values are represented by their own special type. This type is real GraphQL enum only if all values are valid GraphQL identifiers. If at least one value is not valid identifier, the type is declared as scalar instead - allowing free values. Enum values are never sanitized!
Type name is create by concatenating full property path and additional enum, all separated by underscores (and sanitized, of course).
Example (standard schema, excerpt):
{
"comments": {
"cs:feature.key": "censhare:module.oc.comments-settings",
"type": "string",
"enum": [
"disabled",
"enabled",
"moderated"
]
}
}
type article {
comments: article_comments_enum
}
enum article_comments_enum {
disabled
enabled
moderated
}
Localized mapping
Localized mapping is treated similarly to other object sub-schemas and converted to object type. Fields of this type, however, are automatically generated from list of all available schemas and additional special field _ that represents empty or missing language (it is actually sanitation of empty string).
Example (from standard article schema):
type article_content0001 {
_: article_content_
de: article_content_
en: article_content_
fr: article_content_
it: article_content_
ja: article_content_
}
type article_content_ {
assetId: Long
language: String
name: String
#override of STRING mapping
parent: article
richText: String
subtitle: String
title: String
website: article_content__website
}
Arrays (lists)
Array sub-schema is equal to GraphQL list type.
In GraphQL schema, this is represented simply by square brackets around the inner type.
Array fields always have two optional parameters: limit and offset. This allows caller to pick only small subset of the list (usually just the first value). This is not a full paging implementation, though; called cannot determine if there are other pages or not, because there is no way to obtain full list length.
Reference override
Non-inlined relation (or reference) mappings are (by default) represented by scalar value of the same type as defined in schema (Long or String).
To fully exploit the main advantage of GraphQL and query full graph structure, this mapping must be overridden
by declaration of result graphql type. This is done by adding cs:relation.$ref_schema or cs:feature.$ref_schema to the property declaration,
with array of schema names as a value (single string is also possible, in place of single-item array).
This is not done automatically for two reasons:
- At the time of conversion, correct type is not known - in theory, relation can lead to any asset and thus any entity.
- Graph expansion is not desirable in some cases. For example, media-link to image might be more useful than access to image entity itself.
Missing schemas are silently ignored; as long as at least one referenced schema from the array exists, schema is accepted. This behavior allows declaring reference to schema that will be added in future - unlike $ref and mixins, cs:relation.$ref_schema does allow circular dependencies.
If there is exactly one valid referenced schema, corresponding type is directly used in GraphQL; otherwise, auxiliary union type is automatically created.
Successful override is also marked by comment in form "override of TYPE mapping".
Example (simple schema for Kanban/Scrumm-style board):
{
"id": {
"minValue": 0,
"maxValue": 9007199254740991,
"cs:feature.key": "censhare:asset.id",
"type": "integer"
},
"description": {
"type": "string",
"cs:storage.item": "master",
"cs:storage.$path": "description",
"cs:storage.$xml": "card-content",
"cs:$string_type": "xml"
},
"name": {
"cs:feature.key": "censhare:asset.name",
"type": "string"
},
"members": {
"type": "array",
"items": {
"type": "string",
"cs:relation.direction": "child",
"cs:relation.key": "user.project-person.",
"cs:relation.$ref_schema": ["person","account"],
"cs:relation.$ref_type": "link"
}
},
"board": {
"type": "integer",
"cs:relation.direction": "parent",
"cs:relation.$ref_schema": ["board"],
"cs:relation.key": "user.board-entry."
}
}
union _union_person_account = account | person
type card {
#override of INTEGER mapping
board: board
id: Long
#override of STRING mapping
members( limit: Int, offset: Int = 0 ): [_union_person_account]
name: String!
description: String
}
Special top-level types for version 4 and higher
These GraphQL types actually represent the API itself.
Special type any
This type is defined if there is at least one schema available.
It is union of all schema types.
Special type Entities
This type is always defined and contains one property for each schema (the name is the same as the sanitized schema name). This property then contains properties (note that names are hardcoded):
- single(id: Long!)
- This field implements simple retrieval by entity ID and is equivalent to REST endpoint /entity/{schema}/{id}
- The field has one mandatory argument id of type Long.
- Field type is simply the converted schema type.
- list(limit: Int = 100, offset: Int = 0, order: String, query: String)
- This field implements entity listing and is equivalent to REST endpoint /entity/{schema}
- There are several optional arguments that correspond to parameters described in
REST API: Paging and Ordering
- query contains a query expression. When missing, all entities are returned.
- order contains an order specifier (comma-separated list of property paths).
- limit and offset specify the requested page; default is the first page.
- Field type is special object type with fields that corresponds to paging result from REST API:
- limit and offset contain real, effective values of limit
- total_count contains total count of all results, regardless of the requested page. Corresponds to total-count property of paging result in REST API.
- count contains size of the result list (limited by requested page).
- result is the real result - list of entities found.
- Keys
- This is field is optional and only appears if there is at least one single custom key defined.
- Each defined key is represented by single field that does entity lookup:
- Field name is just the key name, sanitized.
- Field has appropriate number of arguments of type String, all of them are mandatory.
- If the key is a simple one with just single part, parameter is named simply key.
- Composite keys require several arguments, with numbered names starting key_part_000, then key_part_001, etc.
- Field type is simply the converted schema type, just like for single-entity query above.
Special type Views
This type is defined only if there is at least one public view key defined in some schema.
It is principally similar to Entities type, in the same way that /view REST API is similar to /entity REST API. Instead of schemas, the properties are named views. The list property is the same, the single property takes one or more string arguments as the key parts.
Special type Query
This type is quite static, only the Views property is optional (it's present only when at least one global view is defined).
type Query {
Entities: Entities
Views: Views
any(limit: Int = 100, offset: Int = 0, order: String, query: String!): _paging_any
}
Complex Example (v4.0)
Example with three entities, one of whose has some custom keys that also server as global views.
union any = image | product | video
type Query {
Entities: Entities
Views: Views
any(limit: Int = 100, offset: Int = 0, order: String, query: String!): _paging_any
}
type Entities {
any(limit: Int = 100, offset: Int = 0, order: String, query: String!): _paging_any
image: _Entity_image
product: _Entity_product
video: _Entity_video
}
type Views {
product_history: _GlobalView_product_history
product_latest: _GlobalView_latest
}
type _GlobalView_product_history {
single(key_part_000: String!, key_part_001: String!): product
list(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_product
}
type _GlobalView_product_history {
single(key: String!): product
list(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_product
}
type productKeys {
history(key_part_000: String!, key_part_001: String!): product
latest(key: String!): product
}
type _Entity_image {
list(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_image
single(id: Long!): image
}
type _Entity_product {
Keys: productKeys
list(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_product
single(id: Long!): product
}
type _Entity_video {
list(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_video
single(id: Long!): video
}
Special types for versions before 4.0
The top-level type Query is quite different: it contains two properties for each schema, plus any just like in the new version. The Views property is optional and its structure is similar to the Query structure (ie two properties for each view).
For each non-abstract entity schema, two or three fields are present:
- single-entity query
- This field implements simple retrieval by entity ID and is equivalent to REST endpoint /entity/{schema}/{id}
- Field name is the same as the type name (i.e. sanitized schema name).
- The field has one mandatory argument id of type Long.
- Field type is simply the converted schema type.
- listing query
- This field implements entity listing and is equivalent to REST endpoint /entity/{schema}
- Field name is created from schema name by appending s and then sanitizing. This results in plural form in most cases, but no special cases of english language are handled - for schema company, listing endpoint is companys and not the correct companies!
- There are several optional arguments that correspond to parameters described in
REST API: Paging and Ordering
- query contains a query expression. When missing, all entities are returned.
- order contains an order specifier (comma-separated list of property paths).
- limit and offset specify the requested page; default is the first page.
- Field type is special object type with fields that corresponds to paging result from REST API:
- limit and offset contain real, effective values of limit
- total_count contains total count of all results, regardless of requested page. Corresponds to total-count property of paging result in REST API.
- count contains size of the result list (limited by requested page).
- result is the real result - list of entities found.
- defined custom keys; optional, present only if there is at least one single key defined
- Field name is created from schema name by appending Keys and then sanitizing.
- Each defined key is represented by single field that does entity lookup:
- Field name is just the key name, sanitized.
- Field has appropriate number of arguments of type String, all of them are mandatory.
- If the key is a simple one with just single part, parameter is named simply key.
- Composite keys require several arguments, with numbered names starting key_part_000, then key_part_001, etc.
- Field type is simply the converted schema type, just like for single-entity query above.
One special field is always present: any. This field corresponds to /query endpoint in REST API and provides mechanism to search in multiple entities at once (mixed queries). It is very similar to the listing field of each schema, except that
- query argument is mandatory
- result in the result is list of any (union of all types).
any(limit: Int = 100, offset: Int = 0, order: String, query: String!): _paging_any
type _paging_any {
count: Int
limit: Int
offset: Int
result: [any]
total_count: Int
}
Complex Example (pre-v4.0)
Example with three entities, one of those has some custom keys that also serve as global views.
union any = image | product | video
type Query {
Views: Views
any(limit: Int = 100, offset: Int = 0, order: String, query: String!): _paging_any
image(id: Long!): image
images(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_image
product(id: Long!): product
productKeys: productKeys
products(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_product
video(id: Long!): video
videos(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_video
}
type productKeys {
history(key_part_000: String!, key_part_001: String!): product
latest(key: String!): product
}
type Views {
product_history(key_part_000: String!, key_part_001: String!): product
product_historys(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_product
product_latest(key: String!): product
product_latests(limit: Int = 100, offset: Int = 0, order: String, query: String): _paging_product
}
Access control
From security perspective, accessing entities via GraphQL is the same as accessing them via corresponding REST endpoints: request needs sufficient roles to allow READ operation for that entity/schema. This includes:
- any property in query schema (generic query)
- result is already filtered to contain only accessible entities
- listing-query ("plural") property in query schema (schema query)
- result is already filtered to contain only accessible entities
- single-entity query in query schema
- if the request lack required permissions, result is graphql error
- overridden reference in entity
- if the request lack required permissions, result is graphql error
Note that permissions are checked only for entities/schemas that are actually accessed.
For detailed documentation of security model, see Security and Permissions