Pavel Simonov

Город IT · 2023 · Pavel Simonov

Records API: rethinking GraphQL for a low-code platform

An edited transcript based on the recording’s automatic Russian captions and slides; it is not a verbatim transcript checked against the audio. Repetitions and filler words have been removed; technical names were checked against the slides. Unclear passages are marked. The content retains the context of the 2023 talk.

The original slide images remain in Russian. The transcript is available in the selected language; code and API identifiers are not translated.

Contents

1 / 36Records API

Original slide 1: Records API
Original slide · RU This part in the video

Talk transcript

Hello! I’m Pavel Simonov, a systems architect at Citeck. This talk is about Records API, our own development and a rethinking of GraphQL for a low-code platform.

2 / 36How it started

Original slide 2: How it started
Original slide · RU This part in the video

Talk transcript

First, a little history. Like many others, we had been developing our system with a REST API for a long time. Everything was fine until we discovered that a relatively simple table, with a few fields and a hundred records, took ten seconds or more to load data from the backend.

We investigated and found that the backend was returning so much data for every row that the scale of it was alarming.

3 / 36Overfetching

Original slide 3: Overfetching
Original slide · RU This part in the video

Talk transcript

We had encountered overfetching: the client needs specific data, but the server returns much more. The slide illustrates this with a question: where, in all of this, is the user’s email?

A common quick fix is to split one large request into many small, task-specific requests, each returning only particular data. But that was not our approach: we were building a platform.

4 / 36A low-code platform

Original slide 4: A low-code platform
Original slide · RU This part in the video

Talk transcript

We believe robots should do the hard work, rather than people. If something can be automated, it should be automated.

In conventional development, adding a new type and its entities means implementing an entity, repository, service, controller and perhaps more. That is not low-code. We needed a different approach.

5 / 36A new type: ID and name

Original slide 5: A new type: ID and name
Original slide · RU This part in the video

Talk transcript

In our platform, we open the types section and create a new type, specifying its ID and name. The slide shows the power-of-attorney type, «Доверенность», with the ID attorney.

6 / 36A new type: attributes

Original slide 6: A new type: attributes
Original slide · RU This part in the video

Talk transcript

Next, we configure attributes. This example has two: name and number. We configure them and create the type. In the screenshot, name is Text and number is Number.

7 / 36Adding a table to the menu

Original slide 7: Adding a table to the menu
Original slide · RU This part in the video

Talk transcript

After creating the type, we go to the menu editor and add a table to display its entities. The screenshot calls this menu item «Журнал».

8 / 36Selecting the new type

Original slide 8: Selecting the new type
Original slide · RU This part in the video

Talk transcript

We search for the required type, select it and save the menu configuration.

9 / 36The generated table

Original slide 9: The generated table
Original slide · RU This part in the video

Talk transcript

Opening the menu reveals a ready-made table with the attributes we just configured. We click the plus button to create a new entity.

10 / 36An automatically generated form

Original slide 10: An automatically generated form
Original slide · RU This part in the video

Talk transcript

An automatically generated form appears. We enter data and save it. In this example, the power of attorney has a name and number.

11 / 36The document’s detail view

Original slide 11: The document’s detail view
Original slide · RU This part in the video

Talk transcript

After saving, the document’s detail view appears. We did not need to implement a separate form and detail view for this new type.

12 / 36The record in the table

Original slide 12: The record in the table
Original slide · RU This part in the video

Talk transcript

We return to the table, refresh it and see our record. The whole process is simple and quick.

13 / 36What is created on the backend

Original slide 13: What is created on the backend
Original slide · RU This part in the video

Talk transcript

The backend creates a relatively simple table. It contains metadata: who created the record and when, and who modified it and when. It also contains the fields we configured, name and number.

This low-code platform makes it possible to handle such tasks flexibly. Now let’s return to overfetching.

14 / 36Why we considered GraphQL

Original slide 14: Why we considered GraphQL
Original slide · RU This part in the video

Talk transcript

We looked for a solution, and GraphQL seemed the most obvious choice. At first glance it fits: the client specifies what it needs, and the backend returns only that. The problem appears to be solved.

15 / 36GraphQL did not fit

Original slide 15: GraphQL did not fit
Original slide · RU This part in the video

Talk transcript

But when we started integrating GraphQL, fitting it into our low-code platform felt like forcing a square peg into a round hole. It did not fit. Let’s look at the specific problems we encountered.

16 / 36GraphQL’s drawbacks for our platform

Original slide 16: GraphQL’s drawbacks for our platform
Original slide · RU This part in the video

Talk transcript

First, the mandatory schema. Normally this is a major benefit: a contract between client and server, so the server knows what to return and the client knows what to expect. But our entities are created on the fly. An analyst can add types just by clicking. Trying to generate the schema automatically or make it universal was painful.

Second, generic clients are difficult to build. Our frontend cannot depend on specific business entities, such as a contract class. We work with data, maps and arrays, keeping things as generic and flexible as possible.

Third, the backend can also be a client. Microservices should communicate through the same API: why invent it twice? Customers also ask how to integrate with our system. We want to offer them the API the browser uses.

Fourth, the GraphQL format is not as widespread as we would like. A great deal is invested in promoting it, and online and offline editors exist. But support for familiar formats is available in almost any tool.

17 / 36What we liked about GraphQL

Original slide 17: What we liked about GraphQL
Original slide · RU This part in the video

Talk transcript

There are advantages we liked very much. The client knows exactly the type of result it will receive; GraphQL achieves this through its schema.

There are no underfetching or overfetching problems, and there is a single endpoint. Creating new endpoints for routine tasks is unnecessary in our system. We wanted to retain these properties.

18 / 36Records API’s basic concepts

Original slide 18: Records API’s basic concepts
Original slide · RU This part in the video

Talk transcript

We developed our own API and named it Records API. To understand it, let’s define three basic terms and their rules.

A microservice here is an application with a unique name within the platform. A Record is any resource in the system. A data source is a DAO for records that implements all or some CRUD operations: create, read, update and delete. Its ID is unique within its microservice.

Each record has a global ID. emodel/person@admin has three parts: the microservice name emodel, the data source person after the slash, and the record’s local ID admin after @.

Example from the slide

emodel/person@admin

19 / 36A universal query format

Original slide 19: A universal query format
Original slide · RU This part in the video

Talk transcript

We lacked a universal search format. We used to pass objects containing required fields through parameters, which was awkward. We developed a format that answers several questions.

Where do we search? sourceId specifies the application or microservice name, a slash, and the data source ID. How do we search? language specifies the query language. To the API it is any string, but the data source must understand that language.

What do we search for? query is the query itself. It depends on the language, and interpreting it is entirely the data source’s responsibility. It is usually a string or object.

The remaining fields are standard: groupBy specifies grouping attributes; sortBy gives the attribute and sort direction; page defines how many records to skip and take.

Example from the slide

sourceId: String
language: String
query: Any
groupBy: String[]
sortBy: SortBy[] { attribute: String, ascending: Bool }
page: Page { skipCount: Int, maxItems: Int }

20 / 36Attributes and relationships

Original slide 20: Attributes and relationships
Original slide · RU This part in the video

Talk transcript

Attributes are a very important part of the API. They let a client specify the data it needs flexibly and conveniently. Working with IDs is one thing; obtaining the full name of a related counterparty through a contract reference is another.

Consider a simple data graph: a contract has a text name and an association with a counterparty. The counterparty has a full organisation name and an address, also text fields.

21 / 36Nested attributes with dot notation

Original slide 21: Nested attributes with dot notation
Original slide · RU This part in the video

Talk transcript

Suppose these entities contain the data on the slide. Each record has a local ID. The counterparty field stores a reference using the global format described earlier.

The first rule of Records API attributes is to use a dot for nested values. Given a contract reference, request the counterparty’s full name as counterparty.fullOrgName. A longer path looks like association0.association1.someField.

Example from the slide

association0.association1.someField
counterparty.fullOrgName

22 / 36Scalars: the result type

Original slide 22: Scalars: the result type
Original slide · RU This part in the video

Talk transcript

We wanted to retain GraphQL’s advantage of knowing the result type. We have no schema, so we needed another solution. That led to scalars.

Any value in the graph can have different representations. A number can be a number or string; a boolean can also be represented differently. For an entity reference, a client may need either the reference itself or the entity’s display name. The underlying value is the same.

The scalar follows an attribute and a question mark: attribute?scalar. The client states the representation it wants, and the engine converts the result to that type. [Part of the following explanation is unclear in the automatic captions.]

Example from the slide

association0.association1.someField?scalar

23 / 36Loading a contract’s name

Original slide 23: Loading a contract’s name
Original slide · RU This part in the video

Talk transcript

For a simple name lookup, we write name. If no scalar is specified, disp is implied, meaning display name.

For a string, that is its content. For entity references, the display name differs from the reference itself. Thus name and name?disp are equivalent in this example.

Example from the slide

name
name?disp

24 / 36A counterparty’s full name

Original slide 24: A counterparty’s full name
Original slide · RU This part in the video

Talk transcript

To get the counterparty’s full name from a contract reference, follow counterparty, then fullOrgName. To request a string, add str: counterparty.fullOrgName?str.

Example from the slide

counterparty.fullOrgName?str

25 / 36Attribute postprocessors

Original slide 25: Attribute postprocessors
Original slide · RU This part in the video

Talk transcript

Besides using scalars to represent values, we often need to process an already calculated result. That is what postprocessors do.

The most common is or. If the result is null or an empty string, it returns a specified value. There is syntactic sugar for this: an exclamation mark followed by the default value. counterparty.fullOrgName?str!"unknown" is equivalent to counterparty.fullOrgName?str|or("unknown").

In general, an attribute is followed by a pipe and a processor with arguments; further processors can be chained. Common operations include number formatting, such as adding leading zeros. [Another postprocessor’s name is unclear in the automatic captions.] There are more specialised operations too.

Example from the slide

counterparty.fullOrgName?str!"unknown"
==
counterparty.fullOrgName?str|or("unknown")

attribute|processor0(arg0,arg1,argN)|processor1(arg0,arg1,argN)

26 / 36HTTP API: searching for records

Original slide 26: HTTP API: searching for records
Original slide · RU This part in the video

Talk transcript

Let’s move from theory to practice. HTTP is one way to use Records API. We always use POST requests. We wanted transport independence, so we do not distribute operations across different HTTP methods.

A search supplies query in the format described and the attributes to retrieve. On the left of attributes is the result’s alias; on the right is the attribute expression. This example finds a user by email, takes one record and requests its local ID as userId.

Example from the slide

POST /gateway/records/query

{
  "query": {
    "sourceId": "emodel/person",
    "language": "predicate",
    "query": {
      "t": "eq",
      "a": "email",
      "v": "admin@citeck.ru"
    },
    "page": {
      "maxItems": 1
    }
  },
  "attributes": {
    "userId": "?localId"
  }
}

27 / 36HTTP API: search response

Original slide 27: HTTP API: search response
Original slide · RU This part in the video

Talk transcript

The result is a list of records. Each always has a global id and the requested attributes. Attribute names on the left are our aliases; values are on the right. For this user, userId is admin. totalCount indicates the number of matching records.

Example from the slide

{
  "records": [
    {
      "id": "emodel/person@admin",
      "attributes": {
        "userId": "admin"
      }
    }
  ],
  "totalCount": 1
}

28 / 36HTTP API: retrieving attributes

Original slide 28: HTTP API: retrieving attributes
Original slide · RU This part in the video

Talk transcript

If the record IDs are already known, send them in a records array and specify the attributes. The principle is the same as in a search.

Here we request two users’ email addresses. email!'undefined' provides a string fallback if an email is missing.

Example from the slide

POST /gateway/records/query

{
  "records": [
    "emodel/person@admin",
    "emodel/person@pushkin"
  ],
  "attributes": {
    "email": "email!'undefined'"
  }
}

29 / 36HTTP API: attribute response

Original slide 29: HTTP API: attribute response
Original slide · RU This part in the video

Talk transcript

The response contains two records, each with an id and calculated attributes. admin has an email. The second record returns the string "undefined", the default specified in the request.

Example from the slide

{
  "records": [
    {
      "id": "emodel/person@admin",
      "attributes": {
        "email": "admin@citeck.ru"
      }
    },
    {
      "id": "emodel/person@pushkin",
      "attributes": {
        "email": "undefined"
      }
    }
  ]
}

30 / 36HTTP API: updating records

Original slide 30: HTTP API: updating records
Original slide · RU This part in the video

Talk transcript

Updates use another URL, /gateway/records/mutate. We specify the record and new attribute values. This example changes admin’s email and address.

Example from the slide

POST /gateway/records/mutate

{
  "records": [
    {
      "id": "emodel/person@admin",
      "attributes": {
        "email": "new-email@citeck.ru",
        "address": "Moscow, Pushkina st."
      }
    }
  ]
}

31 / 36HTTP API: update response

Original slide 31: HTTP API: update response
Original slide · RU This part in the video

Talk transcript

An update normally returns the same ID. Here attributes is empty. But a mutation request can also specify which attributes to retrieve from its result alongside records. This is supported and used in some places; those attributes would then be populated in the response.

If we were creating a record, the response would contain the new entity’s ID.

Example from the slide

{
  "records": [
    {
      "id": "emodel/person@admin",
      "attributes": {}
    }
  ]
}

32 / 36HTTP API: creating records

Original slide 32: HTTP API: creating records
Original slide · RU This part in the video

Talk transcript

Creation uses the same mutation endpoint. There is nothing after @ in emodel/person@, indicating a new entity.

Supplying a local id in attributes can allow updating an existing record with that ID; otherwise a record is created with the supplied ID. Here we provide pk.simonov, email and address.

Example from the slide

POST /gateway/records/mutate

{
  "records": [
    {
      "id": "emodel/person@",
      "attributes": {
        "id": "pk.simonov",
        "email": "pavel.simonov@citeck.ru",
        "address": "Tomsk, Pushkina st."
      }
    }
  ]
}

33 / 36HTTP API: creation response

Original slide 33: HTTP API: creation response
Original slide · RU This part in the video

Talk transcript

The response has the same structure as an update, but contains the created record’s ID: emodel/person@pk.simonov in this example.

Example from the slide

{
  "records": [
    {
      "id": "emodel/person@pk.simonov",
      "attributes": {}
    }
  ]
}

34 / 36HTTP API: deleting records

Original slide 34: HTTP API: deleting records
Original slide · RU This part in the video

Talk transcript

For deletion, supply a list of IDs. The response contains an operation status, OK or another result, for each record.

Example from the slide

POST /gateway/records/delete

{
  "records": [
    "emodel/person@admin",
    "emodel/person@pushkin"
  ]
}

{
  "statuses": [
    "OK",
    "OK"
  ]
}

35 / 36The demo project

Original slide 35: The demo project
Original slide · RU This part in the video

Talk transcript

That completes the main part. There is a demo project you can download and run. It is independent of our platform, with the Records API library and a test data source that stores data in memory.

This is a frontend track, but the API also has interesting backend aspects. It is friendly to both: a client requests a representation, a source returns its own, and the attribute syntax connects them. When necessary, the engine converts strings to numbers or numbers to strings.

https://github.com/Citeck/ecos-records-demo

Questions after the talk

Is there a frontend client?

Question: is there a ready-made client, or should requests be sent directly, for example with Axios?

Answer: our platform has a client, mainly for optimisation. When different components need user data or attributes of different entities, it combines the requests and sends them together. But that is a benefit of the approach: you can construct ordinary JSON and use any HTTP client. An additional wrapper exists within the platform.

Can a universal API read all the data?

Question: if the client is not tied to business entities, can it collect all information?

Answer: I meant the frontend must not depend on specific business entities, because they are dynamic and created on the fly. The platform’s permission engine restricts access: records without view permission cannot be requested. Separate attribute permissions can prohibit reading or modifying individual fields.

Access checks belong to the Record Source or Records DAO implementation. We have shared implementations for different types, but this is outside the API protocol itself.

How do filtering and searching related data work?

Question: are selection conditions and searches supported, including nested reference data?

Answer: scalars can have arguments, usually for searching, but each implementation determines how to process them. Records Query handles general searches: source, language, conditions and pagination. A generic table sends one query format to the selected source and gets a response regardless of data type.

Searches through associations have also been added. If data is in one database schema, a path such as user.email can be used. The backend identifies user as an association, resolves the related entity’s type and sourceId, finds its table and adds a JOIN to the SQL query.

How complex can queries be?

Question: is there a limit to query complexity?

Answer: the shared engine does not cover every possible query. It implements basic operations such as equals, not equals, starts with and ends with, plus the related-table searches discussed above. A more complex task may require a separate data source.