GraphQL formatters

This page describes the usage of the formatter class to make formatting GraphQL types (and fields) easier and more consistent. Objects can be models, entities, records, etc.

The current GraphQL flow is as follows:

  1. Client sends request for GraphQL Query.
  2. Query resolver for the given query is called.
  3. The query resolver loads the records from the database and passes the records on to the type resolver.
  4. The type resolver needs to resolve each field and apply the correct format. 

In older code without proper models and a lot of special treatment of different fields, a generic solution might not be applicable. For newer (or better structured) code, a unified way to apply formatting is preferable.

It can be tempting to simply use large if/else or switch/case statements when dealing with fields in the type resolvers.

public static function resolve(string $field, $object, array $args, execution_context $ec) {
    ...
    $format = $args['format'] ?? null;

    if ($field == 'field1') {
        return format_string($field, ...);
    } else if ($field == 'field2') {
        return format_...
    } else if ($field == 'field3') {
        return ...
    }
    ...
}

Depending on the size of the type, this can make the resolver difficult to read and maintain, and potentially error-prone. Additionally it is not reusable, so in other places where you need to apply a format you would need to create duplicated code. 

Formatter

A formatter takes care of the formatting of each field of a GraphQL type.

Usage of a formatter in GraphQL
class my_type_resolver implements \core\webapi\query_resolver {

    public static function resolve(string $field, $competency, array $args, execution_context $ec) {
        ...
        $context = ...
        $format = $args['format'] ?? null;

        $formatter = new my_formatter($competency, $context);
        return $formatter->format($field, $format);
    }

}

The formatter must be instantiated with:

  • The object it should format
  • The context for which it should apply the format

At a minimum the formatter needs to implement the get_map() function, which should return a map of fields and format functions to apply. 

class my_formatter extends \core\webapi\formatter {

    // This shows all variations, in reality this would look a bit cleaner and more concise
    protected function get_map(): array {
        return [
            // fields with null value won't be formatted
            'id' => null,
            // You can pass a simple Closure just to modify the value
            'shortname' => function ($value) {
                return s($value);
            },
            // You can pass a class name of an existing field formatter
            'fullname' => string_field_formatter::class,
            // You can pass a Closure type-hinting your field formatter to automatically instantiate it
            'description' =>  function ($value, \core\webapi\formatter\field\text_field_formatter $formatter) {
                global $CFG;
                require_once($CFG->dirroot . '/totara/hierarchy/lib.php');

                $component = 'totara_hierarchy';
                $filearea = \hierarchy::get_short_prefix('competency');
                $itemid = $this->object->id;

                return $formatter
                    ->set_options($this->context, $component, $filearea, $itemid)
                    ->format($value);
            },
            // You can use a function name of the current formatter
            'timecreated' => 'format_custom_date',
            'timemodified' => \core\webapi\formatter\field\date_field_formatter::class
        ];
    }

}

If the field is not defined in the map, an exception will be thrown. If the field is set to null in the map then it won't apply any formatting, and will just return the field directly.

The function for a field can be:

  • An existing field formatter class name or instance (we ship it with string_field_formatter, text_field_formatter, date_field_formatter)
  • Any custom method name defined within the same class
  • A Closure taking only the value as argument, for simple value modifications
  • A Closure taking the value and a type-hinted field formatter, the field formatter will be automatically instantiated

If using a custom method in the current class, the method must be public, and the method signature is:

public function format_custom_date($value, $format) {
    // Custom code here.
    return $formatted_value;
}

By default, the formatter can work with stdClass and arrays, but you can also make it work with any object.

In this case, just extend it and overwrite the functions get_field, has_field, e.g.:

    protected function get_field(string $field) {
        // any custom function to get the field
        return $this->object->get($field);
    }

    protected function has_field(string $field): bool {
        return $this->object->has($field);
    }

The formatter can be tested independently if needed.

Field formatters

Field formatters are responsible for formatting a value in a certain way. Each field formatter should only tackle a specific formatting, such as text, string, date, etc.

We provide three general field formatters:

  • string_field_formatter → runs value through format_string()
  • text_field_formatter → runs value through format_text()
  • date_field_formatter → formats date in various ways

Additional field formatters can be implemented either in core or in plugins by extending the base formatter.

The individual field formatters can implement the following functions:

Field formatter example
class custom_field_formatter extends \core\webapi\formatter\field\base {

    protected function validate_format(): bool {
        // validate whether $this->format is the expected one
		// A good practice is to validate against constants
		// for example:
		return defined('self::FORMAT_.'.strtoupper($this->format));
    }

    protected function get_default_format($value) {
		// This is called when there's no specific format_...() function
        // If all relevant formats are covered by functions you do not need to override this
        return ...;
    }

    protected function format_html($value) {
        // This is called for format == 'html'
        // implement functions for your specific format by following the format_[formatname]() pattern
    }

}