-
Notifications
You must be signed in to change notification settings - Fork 4
schema language
This is a draft page filled as we go with the language implementation. The
ultimate source of truth for the syntax is schema.bnf
.
Epigraph schema files have an .epigraph
extension and consist of a namespace
declaration followed by imports followed by type and resource declarations.
In the following sections angle brackets will be used for non-terminal parts of the syntax, with optional parts followed by a question mark, for example:
list[<value type>] <type name> <extends clause>?
Schema file consists of:
- Mandatory namespace declaration
- A list of imports
- Arbitrary number of type declarations, stand-alone named projections, resource declaraions and transformers
General points
- Schema is case-sensitive
- White spaces have no semantics
- Most of the commas are optional, for instance between field or parameter declarations
- C++ style single and multi-line comments are supported
Namespace declaration is mandatory and consists of a namespace
keyword
followed by a fully-qualified namespace. Fully-qualified namespace is a list of
dot-separated namespace names forming a hierarchy, similar to Java packages.
Namespace names must be lower-cased.
Namespaces are used to organize types and resources into logical groups. They may also be translated by the code generators into appropriate language constructs such as packages.
There are two kinds of imports: namespace imports and single type imports.
Single type import makes one single type from another package available:
import foo.bar.Baz
record R {
myField: Baz
}
Importing types with the same short name from different namespaces is not allowed:
import foo.bar.Baz
import qux.Baz // clash!
Namespace import brings given namespace into the scope, so that namespace prefix can be omitted, except for the last segment:
import foo.bar
record R {
myField: bar.Baz
}
Importing namespaces with the same last segment is not allowed:
import foo.bar
import qux.bar // clash!
The following imports are always implicitly present:
import epigraph.String
import epigraph.Integer
import epigraph.Long
import epigraph.Double
import epigraph.Boolean
Type references are resolved in the following order:
- Using explicit imports
- Using implicit (standard) imports
- Types from the same (current) namespace
See References implementation for more technical details.
Here's a full list of reserved keywords that can't be used as type names, field names, parameter names etc.
CREATE
CUSTOM
DELETE
GET
POST
PUT
READ
UPDATE
abstract
boolean
default
deleteProjection
double
enum
extends
forbidden
import
inputProjection
inputType
integer
integer
list
long
map
meta
method
namespace
outputProjection
outputType
override
path
projection
record
required
resource
string
supplement
supplements
transformer
vartype
with
Names given to types, resources, fields etc must follow certain naming conventions.
Any name is either a
- string that starts with a letter, only contains letters or digits and is not one of the keywords
- or an arbitrary string of any characters except backticks, enclosed in backticks
Keep in mind that codegens may not be happy if you go too creative with the latter case.
Type names must start with an upper case. All other names must start with a lower case.
Annotations are pieces of data attached to various schema parts: types, fields, operations etc. Annotations are identified by their types. Any schema type can act as an annotation. General syntax is:
@<type reference> <data expression>
Where type reference is annotation type reference and data expression is annotation value. Only one annotation of any given type can be attached to any schema part.
epigraph.annotations
namespace contains a few built-in annotations such as Doc
and Deprecated
.
Annotation value is optional, type-specific default will be substituted if it's missing:
- empty object for records/maps/lists
-
true
for booleans - zero for numeric types
- empty string for strings
Examples:
record UserRecord {
@Doc "User data"
name: Name
firstName: String {
@Deprecated { message: "use `name` instead", since: "1.3", replaceWith: "name" }
}
lastName: String { @Deprecated /* go figure why */ }
}
Type declarations allow to define new custom named types. Custom type can be one of the following supported kinds:
- entity
- record
- map
- list
- primitive
- enum (unimiplemented as of writing)
Further definitions will be using a few building blocks:
Type reference is a type name that can be resolved, for instance
String
or foo.bar.Baz
Value type is an entity type reference or model value type. It describes a field, map entry or a list element type: it can be a either a type reference, or an inline anonymous map or list declaration.
Model value type is a model type reference or an anonymous map or an anonymous list. It is a subset of a value type that can't resolve to an entity type and is used for entity tags. This is important because entity types can't act as each others entity models.
Is an inline list declaration without a name. Syntax is
list[<value type>]
Examples:
list[String]
list[some.Type]
list[list[list[Boolean]]]
Is an inline map declaration without a name. Syntax is
map[<model value type>, <value type>]
Notable limitation here is that only model types can act as map keys, entity types are not allowed. Keys are also disallowed to have metadata associated with them.
Examples:
map[String, String]
map[some.UserId, some.UserRecord]
Entity types describe entities that may have multiple representations, or models. Models are identified by unique named tags and their values are model value types. This means that entity types can't act as other entity models.
Entity type declaration syntax:
entity <type name> <extends>? <supplements>? <meta>? <body>?
Where extends
is extends clause, supplements
is [supplements clause](#supplements clause),
meta
is metadata clause.
Optional body is a list of annotations and tag declarations enclosed in curly braces.
Tag declaration syntax:
<override>? <tag name>: <model value type> <tag body>?
tag name obeys usual naming convetions.
Optional tag body is a list of annotations enclosed in curly braces.
Examples:
entity Creature
entity Person extends Creature {
@Doc "Person entity type"
id: PersonId
}
entity User extends Person {
override id: UserId { @Doc "User ID" }
}
Entity type inherits all the tags from it's parents and there should be no type conflicts between them. Type of the overriding tag must be a sub-type of the overriden tag (or be the same type). Annotations on overriding tags hide annotations coming from overriden tags.
override
modifier is optional, but a tag must override some other tag if it is present.
Primitive declaration syntax
<primitive kind> <type name> <extends>? <supplements>? <meta>? <body>?
Where extends
is extends clause, supplements
is [supplements clause](#supplements clause),
meta
is metadata clause.
primitive kind
is one of string
, long
, integer
, boolean
, double
.
Optional body is a list of annotations in curly braces.
Primitive types can only inherit other primitive types of the same pimitive kind, i.e a long
can only extend
another long
and can't extend a double
.
Examples:
long PersonId
long UserId extends PersonId { @Doc "User ID" }
Record type declaration syntax:
record <type name> <extends>? <supplements>? <meta>? <body>?
Where extends
is extends clause, supplements
is [supplements clause](#supplements clause),
meta
is metadata clause.
Optional body is a list of annotations and field declarations enclosed in curly braces.
Field declaration syntax:
<override>? <field name>: <value type> <field body>?
field name follows naming conventions.
Optional field body is a list of annotations enclosed in curly braces.
Examples:
record PersonRecord {
@Doc "Person record type"
id: PersonId
name: String
}
record UserRecord extends PersonRecord {
override id: UserId
profile: Url { @Doc "User profile URL" }
}
Record type inherits all the fields from it's parents and there should be no type conflicts between them. Type of the overriding field must be a sub-type of the overriden field (or be the same type). Annotations on overriding fields hide annotations coming from overriden fields.
override
modifier is optional, but a tag must override some other tag if it is present.
Syntax:
map[<model value type>, <value type>] <type name> <extends>? <supplements>? <meta>? <body>?
Where extends
is extends clause, supplements
is [supplements clause](#supplements clause),
meta
is metadata clause.
Optional body is a list of annotations enclosed in curly braces.
Limitations:
- only model types can act as map keys, entity types not allowed
- map keys may not have metadata associated with them
- maps can only inherit maps with the same key/value types
Examples:
map[UserId, UserRecord] UserMap meta Pagination {
@Doc "Users map"
}
Syntax:
list[<value type>] <type name> <extends>? <supplements>? <meta>? <body>?
Where extends
is extends clause, supplements
is [supplements clause](#supplements clause),
meta
is metadata clause.
Optional body is a list of annotations enclosed in curly braces.
Lists can only inherit lists with the same element types.
Examples:
list[User] UsersList Pagination { @Doc "Users list" }
Most of custom types support inheritance. Common limitation is that only type of the same kind may be inherited, for instance a record can extend another record but can't extend a primitive. Specific kinds impose their own additional constraints which are discussed in corresponding sections.
Multiple inheritance is supported. Circular inheritance is not allowed. Linearization algorithm similar to Scala trait linearization is used to flatten out inheritance hierarchies and resolve diamond problem.
Inheritance relationship can be initiated by both child side (using extends
clause) and parent side
(using supplements
clause). It can also be declared using standalone supplement
statement. This means that
in addition to traditional way of defining subtypes, it is also possible to inject supertypes into already
existing types.
Any annotations specified for child types hide annotations of the same type coming from parent types.
Extends clause provides a list of types that are inherited by a given type. Every list element is a type reference of the same kind. Examples:
extends String
extends foo.Bar, bar.Baz
Supplements clause specifies a list of type that will inherit a given type. Every list element is a type reference of the same kind. Examples:
supplements some.UserRecord, some.other.PersonRecord
Supplement statement allows to establish inheritance between types defined somewhere else. General syntax is
supplement <comma-separated list of type references> with <type reference>
For example
supplement some.UserRecord with twitter.WithTwitterAccount
All custom named names can have metadata type associated with them using meta <type reference>
clause. This allows
actual data to have optional metadata associated with it at run time. Usual use case is passing pagination information
such as cursors around.
Some limitations: metadata types can only be model types (no entity types) and it must be compatible with parent types metadata. "Compatible" means that metadata types should form a hierarchy and our meta type should be at the bottom of it, i.e. be more specific than any of the parents.
Projections act as stencils on types limiting them to specific shapes. For every type kind there exists a projection kind, meaning there are entity projections and model projections. Model projection is either a record, a map, a list or a primitive projection.
Projections always apply to some types which are either explicitly specified, as in stand-alone projections, or follow from the context, for instance from a field or list element type.
Entity projection applies to an entity type and contains a list of tags with their model projections. Syntax is:
:( <tags> )
Where <tags>
is a list of tag projections separated by optional commas. Tag projection is a tag name
followed by model projection, corresponding to tag model. For example:
:( id, `record` /* record projection goes here */ )
id
is a primitive, so no further projection is specified. record
is a keyword, so it has to be escaped.
There's a simplified version if only one tag is needed: :<tag projection>
, which allows to skip parenthesis. Example:
:`record` /* record projection here */
TODO: entity projections should have body blocks similar to model projections. This is currently missing from the framework.
Model projection is one of record projection, map projection, list projection or primitive projection. Each of them can be prepended by a body block which contains projection properties.
Record projection is a list of field projections in parenthesis. Field projection is a field name followed by entity or model projection, corresponding to field type.
Example:
( id, firstName, lastName )
Map projection syntax is
[ <key> ] ( <value projection> )
Braces around value projection
are optional and are sometimes needed for clarity.
<key>
is a list of key specifications separated by optional commas. Each element is one of:
-
required
, meaning that map keys are required and must be provided in the request -
forbidden
, meaning that map keys must not be explicitly specified in the request - annotation
- parameter
- key projection, specified using
projection
keyword followed by colon and key model projection. Needed in rare cases when map key is a complex type and only some subset of this type is actually respected. Most prominent use case is deriving federation transformers from operations, in which case key projection becomes transformer input projection.
List projection syntax is
* ( <value projection> )
Braces around value projection
are optional and are sometimes needed for clarity.
Example:
( id, friends * ( id ) )
Primitive types don't have any structure, so projection contains of an optional body block.
Example:
( id { @Doc "this is 'id' field projection documentation" } )
Projection body block is a list of projection properties, enclosed in curly braces that can be placed before the projection. Supported properties depend on projection kind, full list is:
- Annotations
- Parameters
-
Metadata value projection, for model projections. Specified using
meta
keyword followed by colon and metadata model projection - Default values, currently for input projections only. Specified using
default
keyword followed by colon and data expression.
Example:
{
@Doc "User record projection. Only allowed to be requested by members"
@AllowedRoles [ "member" ]
;authToken: String // optional parameter
default: { firstName: "Alfred", lastName: "Hitchcock" }
} ( id, firstName, lastName )
Any projection can be given a name, to do so prepend it with '$ =' clause. Names are scoped by the projection and are not visible outside of it (except for named tails described below). '$' can be used anywhere inside the same expression as a substitute for the original projection. For example:
( id, bestFriend $person = ( firstName, lastName ), worstEnemy $person )
This also allows to create recursive projections:
$person = ( id, bestFriend $person )
There's one special case when names are given to [polymorphic tails](#polymorphic tails), for instance
( id, name ) ~User $user = ( profile )
This creates a [stand-alone projection](#stand-alone projections) in the same scope as enclosing expression, containing normalized version of it. For this example we will get an implicitly defined
outputProjection $user :User = ( profile, id, name )
Projections may have parameter declarations attached to them, describing parameters that may come in the requests. Parameter declaration consists of parameter name, type, 'required' flag and parameter projection (which can be emtpy). Parameters can only contain model, not entity types. Syntax is:
; +? <name> : <type> <projection>
Optional +
marks parameter is required: requests missing it will fail. Parameter name must be a lower-cased string.
<type>
is a model type name, and <projection>
is corresponding model projection (which can contain annotations and
default value).
Examples:
; +authToken: String { @Doc "required authentication token" }
; pagination: PaginationInfo { default: { start: 0, count: 10 } } ( +start, +count )
Both entity and model projections may be followed by so-called polymorphic tails which refine projections for specific subtypes. See this page for in-depth description.
Syntax for entity and model projections is slightly different to disallow ambiguous expressions: model
tails start with ~
while entity tails start with :~
. Using models for simplicity, syntax is:
// single-tail case
<projection> ~<type> <projection>
// multi-tail case
<projection> ~( <type1> <projection1>, <type2> <projection2> , ... )
For example:
( folderItem ( name ) ~( File ( permissions ), Folder ( parent ) ) )
Which will unfold into (permissions, name)
for files and (parent, name)
for folders.
Notice how single-tail syntax allows to create chains using more and more specific types:
( a ~B (b) ~C (c) )
// same as
( a ~( B (b) ~C (c) ) )
It is possible to define named projections for further reuse in other expressions. They can be declared in either resource scope, or on the top level. Visibility will be limited by the resource in the first case and by file in the second. It is not currently possible to import projections from other epigraph files.
Syntax is:
<kind> <name>: <type> = +? <projection>
Where
-
<kind>
is one ofoutputProjection
,inputProjection
,deleteProjection
-
<name>
is projection name. Usual naming conventions apply -
<type>
is projection type reference -
+
is optional and is only allowed for delete projections, marking the whole object as deletable. -
<projection>
is actual projection definition. It can't have a name alias.
Examples
inputProjection company: CompanyRecord = ( +name, address, url )
outputProjection person: PersonRecord = ( id, bestFriend :`record` $person )
Operation projections come in 3 different kinds:
- output projections describe something an operation can produce
- input projections describe some input, for example a parameter value or
CREATE
operation body - update projections describe
UPDATE
operation body. - delete projections
DELETE
operation capabilities: what can or can not be deleted
Their syntax is the same, with the only difference being the semantics of +
flag
which can be put on fields and tags.
+
on output projection specifies which parts constitute default output projection. If something,
for instance a field, is marked with +
, then this field and everything up to it will be included
in the default projection. See default projections for more details.
-
Model projections have 'required' flag meaning that they must be present in the input, if enclosing object is present too. Flag is set using
+
sign before model projection or entity tag name.+
before a field makes all of it's models required.Examples:
( +company :( id, `record` ( +name, ceo :( +id, `record` ( +name ) ) ) ) )
This says:
-
company
field is required, with both of it'sid
andrecord
models -
company:record.name
is required -
company:record.ceo
is optional - if
ceo
is present: it'sid
model is required - if
ceo
is present: it'srecord
model is optional - if
ceo:record
is present, thenceo:record.name
is required
-
-
Projections can have default values associated with them. See projection body block.
Update operation is actually a combination of 'replace' and 'patch' operations. By default 'patch' is used. For example a request like this one
PUT /companies/123:record(name)
Will patch company record changing only name
field. If we want to replace company
record then +
flag should be used:
PUT /companies/123:+record(name)
This will replace thew hole company record leaving only name
field with the new value.
Update operation declaration uses the same +
flag to mark places which can be replaced, for example
company :(
+`record` (
name,
ceo : (
id,
+`record` ( name )
)
)
)
Primitives such as name
can obviously always be replaced.
Delete entity projections have an extra "can delete" flag, telling if this entity can be deleted or not. In other
words, if it can be a leaf node in the request delete projection. This can be specified using +
sign
- before fields in record projections, making fields deletable
- after
[ <key> ]
section in map projections, making values deletable - after
*
in list projections, making list items deletable
All leaf elements are implicitly marked as deletable.
Framework currently doesn't allow to delete individual models.
For example, given the following projection:
( avatar, +bestFriend ( firstName ), friends * + )
It is possible to delete
- avatar
- best friend's name
- best friend itself
- elements of the friends list
Unlink/delete sematics is to be established by the operation and can be controlled using additional parameters.
Paths are special kind of projections used in operation declarations. Main concept is that branching is not allowed, meaning that entity projections must have exactly one tag and record projections must have exactly one field. List and primitive projections may not be part of the path.
Entity path syntax is
:<tag> <body>? <path>
Where <tag>
is tag name, <body>
is optional body block and <path>
is a
model path.
Record path path is
/ <field> <path>
Where <field>
is a field name and <path>
is field value path.
Map path is
. <key>? <path>
Where <path>
is map values path and <key>
is optional block with key attributes. It is similar to what
map projection has, but can't contain forbidden
or required
specifications (since all
path keys are always requried). In other words it can contain annotations,
parameters and a key projection.
As an example imagine a resource of map[PersonId,Person]
type. If we need a separate operation
which will be invoked for requests like
/users/123:record/twitterAccount
then we can define it with the following path:
. :`record` / twitterAccount
Schema file can contain one or more resource declarations. Each resource has a name, a type and a number of operations defined:
resource <name> : <type> {
<body>
}
<name>
is resource name, see naming conventions.
<type>
is resource value type. By default all operation projections and paths start with this type.
<body>
is a list of [projection](#stand-alone projections) and operation defitions, separated
by optional commas.
There are 5 different operation kinds supported by the framework: read
, create
, update
, delete
and custom
. Their declarations syntax is slightly different, but follow the same pattern:
<kind> <name>? {
<body>
}
<kind>
is one of the kinds listed above.
<name>
is operation name, following usual naming conventions. It is optional in all cases except for custom
operation.
There can be more than one unnamed operation, but their projections must be different so that routing algorithm can pick the right one.
Operation names must be unique inside the resource.
<body>
consists of body parts separated by optional commas. Supported parts
vary depending on operation kind.
Supported operation body parts: path, output projection and annotations.
HTTP method is always GET
and output type is always a resource type. Path is optional and, if present,
output projection must start with the path tip type.
Examples:
resource users: map[UserId, User] {
// default read operation
read {
// applies to the map[UserId, User] type
outputProjection [ required ] :( // map keys are required
id,
`record` ( firstName, lastName )
)
}
// named read with input parameter
read searchByName {
@Doc "User search by name operation"
outputProjection {
// required parameter declaration
;name: UserRecord (firstName, lastName)
} [ forbidden ] :( // keys are forbidden, only '*' can be requested
id,
`record` ( firstName, lastName )
)
}
// read with path
read {
path .:record/bestFriend
// output projection applies to best friend entity
outputProjection :(id, `record` (firstName) )
}
}
Supported operation body parts: path, output type, output projection, input type, input projection and annotations.
Input projection describes what request bodies must look like. Certain
parts of input can be marked as required using +
sign, request missing them will fail.
Input projection start with path tip type, if path is present, otherwise with resource
type. This can be overriden using operation input type declaration.
Output projection describes operation output. It should also start with the path tip type or resource type and can be tweaked using output type declaration.
Example
resource users: map[UserId, User] {
create {
@Doc "Users create operation"
// input type can't be a map because UserIds are unknown yet
inputType list[UserRecord]
inputProjection *(
+firstName, // required
lastName // optional
)
// output is a list of created user IDs (or errors)
outputType list[UserId]
// output projection is trivial and can be skipped in this case
}
}
Supported operation body parts: path, output type, output projection, input type, input projection and annotations.
Input projection is mandatory and describes what request bodies must look like. Certain
parts of input can be marked as required using +
sign, request missing them will fail.
Input projection start with path tip type, if path is present, otherwise with resource
type. This can be overriden using operation input type declaration.
Output projection describes operation output. It should also start with the path tip type or resource type and can be tweaked using output type declaration.
Example
resource users: map[UserId, User] {
create {
@Doc "Users update operation"
// keys must be provided in the request
inputProjection [ required ] (
:`record` ( firstName, lastName )
)
// keys can't be requested for output, they will always
// be the same as in the input
outputProjection [ forbidden ] :(
id,
:`record` ( firstName, lastName )
)
}
}
Supported operation body parts: path, output type, output projection, delete projection and annotations.
Delete operations use delete projections to describe what can be deleted. Delete projection is mandatory and must start with resource type or, if path is present, with path tip type.
Output projection describes operation output. It should also start with the path tip type or resource type and can be tweaked using output type declaration.
Custom operations support all possible operation body parts:
- HTTP method
- Optional path
- Optional output type
- Optional output projection
- Optional input type
- Optional input projection
Operation name and HTTP method are mandatory. Semantics of the operation are completely up to the implementation.
This section contains full list of supported operation body parts, with their applicability rules.
Operation method defines HTTP method for the operation, if invoked using HTTP interface.
One of GET
, POST
, PUT
, CREATE
or DELETE
.
Operation path allows to implement operations that apply only to a certain part of the resource type, and path defines where this part starts relative to resource type.
Path always starts with resource type and provides a pattern for request path to match. Path tip type becomes input/output projection type, unless overriden by input/output type.
This allows to break big operations into smaller ones, only responsible for certain subsets of the resource, and later merge them together using server-side federation.
Operation output type override. Overrides resource type (or path tip type, if path is provided). Syntax:
outputType <type>
Operation output projection. Describes what an operation can produce as an output. Can be omitted in primitive cases. Syntax:
outputProjection <projection>
Should start with
- output type, if specified
- else with path tip type, if path is specified
- else with resource type
Operation input type override. Overrides resource type (or path tip type, if path is provided). Syntax:
inputType <type>
Operation input projection. Describes what an operation takes as an input (HTTP request body in case of HTTP). Can be omitted in primitive cases. Syntax:
inputProjection <projection>
Should start with
- input type, if specified
- else with path tip type, if path is specified
- else with resource type
Operation delete projection. Describes what an operation can delete. Syntax:
deleteProjection <projection>
Should start with
- path tip type, if path is specified
- else with resource type