Skip to main content

Language Specification

Namespace

Declared at the top of the Apex document, the namespace is used to identify and refer to elements contained in the Apex document. It may be used by the code generator to target filesystem locations of where to write generated files.

namespace "customers"

Including a version suffix is the recommended way for your application to support multiple versions.

namespace "customers.v1"

Scalar Types

Instead of using the number type for all numeric values, Apex inherits more specific integer and floating point types:

Apex TypeDescription
i8A 8-bit signed integer.
u8A 8-bit unsigned integer.
i16A 16-bit signed integer.
u16A 16-bit unsigned integer.
i32A 32-bit signed integer.
u32A 32-bit unsigned integer.
i64A 64-bit signed integer.
u64A 64-bit unsigned integer.
f32A 32-bit float.
f64A 64-bit float.
boolA boolean.

Apex also includes the following special types that are decoded into language-specific types:

Apex TypeDescription
stringa UTF-8 encoded string.
datetimea RFC 3339 formatted date / time / timezone.
bytesan array of bytes of arbitrary length.
anya language specific type, list, or map value that is determined at runtime.
rawa raw encoded value that can be decoded at a later point in the program.

Collections

Types can be encapsulated in arrays and maps with enclosing syntax:

CollectionApex SyntaxDescription
Array[string]A randomly accessible sequence of values.
Map{string : string}A mapping of keys to values. Keys must be an integer or string. Values can be any scalar type or object type.

Caution about using maps: In some languages, like Go, the iteration order on maps is considered "undefined". This means that the serialized bytes can be different for the same data. Keep this in mind if you need to compare or create hashes of the serialized data. Using arrays of an object type that contains key and value might be preferable.

Optional values

By default, a declared type is required and if unset, contains a zero value (0, "", empty array or map). To make any type optional (nullable), follow it with a ?. For example, string? represents an optional string value.

Functions

Functions are independent operations and allow two styles for accepting input: parameterized and unary.

Parameterized operations

Parameterized operations are familiar function signatures. They accept zero or more parameters and are generated with the same signature defined in Apex. This style is recommended when passing in a small number of fields and follow a simple, familiar, and easy to read format. All parameters must be named. In this example, we pass in first and last names to create a customer and return its new identifier as a u64.

func createCustomer(firstName: string, lastName: string): u64

Unary operations

Now, let's say you want to model a request/reply style operation or you want to accept a large number of fields inside a wrapper object. This is where unary operations are preferred. In contrast to functions, unary operations accept exactly one input.

Instead of using parenthesis (...) to enclose parameters, unary operations use brackets [...] to enclose a single parameter. This is what createCustomer would look like accepting a Customer data type. We'll look at how Customer is defined shortly.

func createCustomer[customer: Customer]: u64

Why the different syntax? In the case of parameterized operations, multiple parameters are possible so the arguments are likely encapsulated in a wrapper object for serialization. For unary operations, since there is a single parameter no wrapper object is required and the input object can be serialized directly. This syntax is necessary for the the code generation tools to generate the appropriate client and server code.

Serialization example

Here is a side-by-side comparison of the serialization differences between parameterized and unary operations.

Parameterized

func greeting(name: string): string

When invoking...

greeting("World")

the input would serialize with a wrapper object in JSON.

{
"name": "World"
}

Unary

func greeting[name: string]: string

When invoking...

greeting("World")

the input would serialize as a simple string in JSON.

"World"

With a unary operation, notice there is no wrapper object or name field. name is used only as the argument name in the generated code.

Here is a request/reply-style operation example using the unary style.

func greeting[person: Person]: string

type Person {
firstName: string
lastName: string
}

Invoking...

greeting(new Person {
firstName: "Apex",
lastName: "Lang",
})

would serialize in JSON as:

{
"firstName": "Apex",
"lastName": "Lang"
}

Interfaces

Interfaces are conceptual groups of operations that allow the developer to divide communication into multiple components. Typically, interfaces are named according to their purpose. For example, a distributed calculator could split each mathematical operation into different roles implemented by different modules.

interface Adder {
add(addend1: i64, addend2: i64): i64
}

interface Subtractor {
subtract(minuend: i64, subtrahend: i64): i64
}

interface Multiplier {
multiply(factor1: i64, factor2: i64): i64
}

interface Divider {
divide(dividend: i64, divisor: i64): i64
}

Interfaces can be either internal or external to your application. Apex code generation modules can make use of Annotations and Directives (described below) to generate either invokers (caller side) or handlers (callee side).

Object types

The most basic component of a Apex schema are object types, which represent a kind of object you can pass to or return from operations, and what fields it has. Types are defined in a language-agnostic way. This means that complex features like nested structures, inheritance, and generics/templates are omitted by design.

In Apex, we might declare Customer like this:

type Customer {
firstName: string
middleName: string?
lastName: string
address1: string
address2: string?
city: string
zipcode: string
email: string
phones: [PhoneNumber]
}

Enumeration types

Enumerations (or enums) are a type that is constrained to a finite set of allowed values.

In Customer, we are allowing multiple mobile, home, or work phone numbers. Here's what a PhoneType enum definition might look like.

type PhoneNumber {
number: string
type: PhoneType
}

enum PhoneType {
mobile = 0 as "Mobile"
home = 1 as "Home"
work = 2 as "Work"
}

Each enum value denotes its programatic / variable name, the integer value that is serialized, and a display or friendly name for printing. Note that Apex does not address internationalization so custom code is required to print the value in multiple spoken languages.

Union types

Unions types denote that a type can have one of several representations.

union Animal = Cat | Dog

The code generator must provide a way to determine the actual type at runtime and handle serialization if necessary.

Alias types

Alias types are used for cases when scalar types (like string) should be parsed our treated like a different data type in the generated code. For example, UUIDs are commonly serialized as strings, but parsed into a specific data type.

alias UUID = string

By default, code generation will treat UUID as a string. However, generators should provide configuration options to parse or cast the the string into a desired data type.

Descriptions

All elements in Apex can have descriptions which serve as documentation throughout the file. The code generation tools should also preserve descriptions as documentation/comments where appropriate so that you only need to worry about documenting functionality in one place.

Descriptions can be a single line or multiple lines.

Single line

"Encapsulates a phone number and its type"
type PhoneNumber {
"The phone number"
number: string
"The phone type"
type: PhoneType
}

Multiple lines

"""
Encapsulates a phone number and its type.
The phone number is a single string value and contains
the country code, area code, prefix, and line number.
"""
type PhoneNumber {
"The phone number"
number: string
"The phone type"
type: PhoneType
}

Default values

Default values for Fields can specified when a type is initialized. Codegen implementation for default values may vary based on limitations of the target language.

type PhoneNumber {
number: string
type: PhoneType = mobile
}

Annotations and Directives

Annotations attach additional metadata to elements. These can be used in the code generation tool to implement custom functionality for your use case. Annotations have a name and zero or many arguments.

Here is what Customer might look like with annotations.

type Customer {
firstName: string @notEmpty
middleName: string? @notEmpty
lastName: string @notEmpty
address1: string @notEmpty
address2: string? @notEmpty
city: string @length(2)
zipcode: string @length(5)
email: string @email @range(min: 5, max: 80)
phones: [PhoneNumber]
}

Multiple annotations can be attached to an element. All annotations have named arguments; however, there are two shorthand syntax options.

ShorthandEquivalent toComments
@notEmpty@notEmpty()Useful for "has annotation" checks.
@length(5)@length(value: 5)value is the default argument name.

The annotation examples above shows a validation scenario but annotations are not limited to this purpose. The developer has the freedom to extend the code generation tool to leverage annotations for their application's needs. In the Customer example, the developer could use these annotations to generate validate methods on each of the generated object types.

Directives are used to ensure that an annotation's arguments match an expected format. For constraining a numeric or length range, the directive for @range might look like this:

directive @range(min: u32, max: u32)
on FIELD
require @valid on TYPE

This directive specifies parameters but also where it can be applied. The syntax on FIELD require @valid on TYPE means "only allow this on fields where there is also an @valid annotation on its type". Multiple locations are supported and delimited with |:

NAMESPACE | INTERFACE | OPERATION | PARAMETER | TYPE | FIELD | ENUM | ENUM_VALUE | UNION | ALIAS

Directives improve the user experience of annotations by giving the developer useful error details when the annotations are invalid. Directives can be in the main file or be made available through imports.

Imports

Apex can integrate external modules using the import command. For example, OpenAPI annotations can be imported with the following statement:

import * from "@apexlang/openapi"

Is is also possible to import specific definitions:

import { info, Contact, License } from "@apexlang/openapi"

namespace "urlshortener.v1"
@info(
title: "Simple URL shortener API"
description: "Simple API for shortening URLs created using Apex."
version: "1.0.0"
termsOfService: "https://api.goodcorp.com/terms/"
contact: {
name: "API Support"
url: "https://api.goodcorp.com/support"
email: "api@goodcorp.com"
},
license: {
name: "Apache 2.0"
url: "https://www.apache.org/licenses/LICENSE-2.0"
}
)