Skip to content
This repository has been archived by the owner on Apr 13, 2019. It is now read-only.

schema language

Konstantin Sobolev edited this page Jan 24, 2018 · 29 revisions

This is a draft page filled as we go with the language implementation. The ultimate source of truth for the syntax is schema.bnf.

Overview

Epigraph schema files have an .epigraph extension and consist of a namespace declaration followed by imports followed by type and resource declarations.

Nomenclature

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>?

General syntax

Schema file consists of:

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

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.

Imports

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!

Standard imports

The following imports are always implicitly present:

import epigraph.String
import epigraph.Integer
import epigraph.Long
import epigraph.Double
import epigraph.Boolean

Resolution sequence

Type references are resolved in the following order:

  1. Using explicit imports
  2. Using implicit (standard) imports
  3. Types from the same (current) namespace

See References implementation for more technical details.

Keywords

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

Naming conventions

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

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

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 declaration building blocks

Type reference

Type reference is a type name that can be resolved, for instance String or foo.bar.Baz

Value type

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

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.

Anonymous list

Is an inline list declaration without a name. Syntax is

list[<value type>]

Examples:

list[String]
list[some.Type]
list[list[list[Boolean]]]

Anonymous map

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 Type

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

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

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.

Map

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"
}

List

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" }

Inheritance

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

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

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

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

Metadata

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

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

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

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

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

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

List projection syntax is

* ( <value projection> )

Braces around value projection are optional and are sometimes needed for clarity.

Example:

( id, friends * ( id ) )

Primitive projection

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

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 )

Named projections

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 )

Parameters

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 )

Polymorphic tails

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) ) )

Stand-alone Projections

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 of outputProjection, 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 )

Output, Input and Delete Projections

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.

Output projection

+ 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.

Input projection

  1. 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's id and record models
    • company:record.name is required
    • company:record.ceo is optional
    • if ceo is present: it's id model is required
    • if ceo is present: it's record model is optional
    • if ceo:record is present, then ceo:record.name is required
  2. Projections can have default values associated with them. See projection body block.

Update projections

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 projection

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

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

Resource Declarations

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.

Read operation

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) )
  }
}

Create operation

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
  }
}

Update operation

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 )
    )
  }
}

Delete operation

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 operation

Custom operations support all possible operation body parts:

Operation name and HTTP method are mandatory. Semantics of the operation are completely up to the implementation.

Operation body parts

This section contains full list of supported operation body parts, with their applicability rules.

Operation method

Operation method defines HTTP method for the operation, if invoked using HTTP interface. One of GET, POST, PUT, CREATE or DELETE.

Operation path

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

Operation output type override. Overrides resource type (or path tip type, if path is provided). Syntax:

outputType <type>

Operation output projection

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

Operation input type override. Overrides resource type (or path tip type, if path is provided). Syntax:

inputType <type>

Operation input projection

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

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

Transformers

See federation/transformers

Clone this wiki locally