-
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)[#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 or
CREATE
operation body - delete projections
DELETE
operation capabilities: what can or can not be deleted
Their syntax is largely the same. Some of them have extra attributes outlined below.
It is very possible that in the future they will be merged into just one kind for simplicity
Output projections don't have any extra attributes.
-
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.
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 body parts: path, output projection and annotations.
HTTP method is always GET
This section contains full list of supported operation body parts, with their applicability rules.