Кратко об OData

Привет, Хабр! Недавно, пришлось работать на проекте с внешним API. Работал, я, к слову, всегда либо с простым REST, либо с GET/POST only запросами, но в этом нужно было работать с API Timetta. Он использует OData и что же это такое?

Содержание

  1. REST vs OData

  2. Схема

  3. Типы данных

    1. Примитивные

    2. EntityType

    3. ComplexType

    4. EnumType

    5. Collection

    6. EntitySet

  4. Запросы

    1. $select

    2. $filter

    3. $expand

    4. $orderby

    5. $top, $skip

    6. $count

  5. Функции, действия

  6. Обновление/добавление ресурсов

    1. Добавление

    2. Обновление

    3. Удаление

  7. Подытожим

  8. Напоследок

    1. Инструменты

REST vs OData

В то время как REST — набор архитектурных правил создания хорошего API, OData — это уже веб-протокол, собравший в себя «лучшие архитектурные практики»: defines a set of best practices for building and consuming RESTful APIs (как написано на официальном сайте). Сам протокол очень большой, поэтому я затрону наиболее практически-значимые аспекты.

Схема

Каждая система использующая OData должна описать свою схему данных. По ней можно понять все: какие сущности есть в системе, какие операции над ними можно производить. Схема может описывается в формате XML или JSON. Для получения схемы нужно сделать запрос по адресу:

/$metadata

Где  — корень сервиса OData. Примеры дальше будут предполагать, что мы делаем запросы из этого . Для Timetta этот адрес такой:

https://api.timetta.com/odata/$metadata

Примеры дальше будут с использованием XML схем.

Типы данных

Примитивные

Протокол определяет ряд встроенных типов данных. Все имеют префикс «Edm». Например:

  • Edm.Boolean

  • Edm.String

  • Edm.Int32

  • Edm.Int16

  • Edm.Stream

  • Edm.Date

  • Edm.Byte

  • Edm.Decimal

  • Edm.Binary

EntityType

EntityType похож на сущность из DDD: у него есть как состояние, так и свой ID (в схеме указывается отдельно). В схеме состоит из элементов

  • Property — поля со скалярными данными. Например, строка или число. Имеет атрибуты:

    • Name — название поля (обязателен)

    • Type — тип поля (обязателен)

    • Nullable — может ли быть null

  • NavigationProperty — поле, которое ссылается на другую сущность

    • Name — название (обязателен)

    • Type — тип (обязателен)

    • ReferentialConstraint — «как» мы ссылаемся

      • Property — название поля в ССЫЛАЮЩЕМСЯ типе

      • ReferencedProperty — название поля в типе, на который ССЫЛАЕМСЯ

  • Key — элемент, определяющий первичный ключ сущности. Значения могут быть только примитивными типами или перечислением и не может равняться null.

    • Name — название поля, которое является ключом

    • Alias — псеводним для ключа. Например, если ключ — поле вложенного типа.

Пример, для сущности TimeSheet, представляющей рабочий график проекта (табель) за некоторый период времени.


  
    
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  	
  
  
  	
  
  
  	
  
  
  	
  
  
  
  
  
  
  
  
  
  	
  
  
  	
  

Объяснение:

  • Поле «id» — первичый ключ

  • Имеет несколько полей с примитивными типами. Например:

    • dueDate — до какой даты нужно закончить

    • dateFrom — когда начинать проект

    • dateTo — когда оканчивать проект

    • approvalInstanceId — Id документа (ресурса), который сигнализирует о том, что работа согласована (если не согласован, то значение — null, нет атрибута Nullable, что по умолчанию означает поддержку null)

    Некоторые из которых могут принимать значение null:

  • Имеет навигационные свойства. Например:

    • approvalInstance — документ согласования

    • timeSheetLines — потраченные часы

    • createdBy — кем создан план работ

    Где:

    • approvalInstance можно найти по занчению поля approvalInstanceId родителя и сопоставление по полю id искомой сущности

    • timeSheetLines — «слабая» сущность. Для нее не нужен Id, время жизни привязано к самому родителю

ComplexType

ComplexType похож на Value type из DDD — не имеет первичного ключа, сравнение по значению полей. Может состоять из тех же элементов, что и EntityType за исключением Key.

Пример, для объекта с 2 полями, который используется для отметок времени


  
  

Объяснение:

EnumType

EnumType — обычный тип перечисления. Особенность в том, как передается значение в параметры — сначала пишется полное имя Enum, затем в кавычках его значение. Для данного примера, чтобы передать PlanningMethod.Manual, нужно написать PlanningMethod'Manual'. Атрибуты:

  • Name — название перечисления (обязательно)

  • UnderlyingType — тип, которое определяет используемое значение (по умолчанию, Edm.Int32)

  • IsFlags — является ли перечисление флагом. Чтобы исползовать в UnderlyingType должно быть проставлено «true»

Для определения занчений — элемент Member:


  
  
  
  

Объяснение:

P.S. В общем случае такой вид записи применяется ко всем типам и имеет вид: ПолныйТипСущности'Значение'. Например, для даты — date'2022–07–01'

Collection

Тип коллекции является отдельным. Определяется как Collection(ПолноеНазваниеТипа). Например, Collection(WP.TimeSheet).


  
    
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  	
  
  
  	
  
  
  	
  
  
  	
  
  
  
  
  
  
  
  
  
  	
  
  
  	
  

Возвращаясь к типу TimeSheet. В нем присутствуют 5 коллекций*:

  • timeSheetLines

  • timeAllocations

  • lineApprovals

  • approvalRecors

  • timeOffRequests

*Тип коллекции может быть и у Property, не только NavigationProperty

EntitySet

У нас есть сущности, сложные типы, перечисления и т.д. Но где это хранить? Для этого нам нужна коллекция.EntitySet — это top-level коллекция доступная всем. Внутри нее хранятся какие-либо сущности. Соответственно, для нее обязательны имя и тип: Name=«SampleName» EntityType=«SampleType», соответственно. По умолчанию, все обычные поля (Property) коллекции включаются в вывод. Если у типа есть навигационные свойства, то их должны указать в EntitySet, иначе их существование не гарантируется.


  
  
  
  
  
  
  
  
  

Здесь:

  • Name=«TimeSheets» — название коллекции. Доступ к ней через это слово: https://app.timetta.com/TimeSheets

  • EntityType=«WP.TimeSheet» — тип сущностей используемый в коллекции (был выше). WP — пространство имен.

  • У коллекции есть несколько навигационных свойств. Свойства — те же, что и у самой сущности. Например:

    • user

    • template

    • modifiedBy

    Но некоторые (total, lineApprovements) отсутствуют.

Есть коллекция, значит есть и единственный элемент. Для получения нужно использовать ID сущности, который указан в схеме. Указывать в круглых скобках после названии коллекции. Например, для получения TimeSheet то его ID нужно сделать запрос:

/TimeSheets (00000000–0000–0000–0000–000000000000)

Запросы

На мой взгляд, что примечательного в OData — язык запросов. Можно делать запросы к ресурсам прямо в строке запроса. Для запросов есть свой специальный язык. По фукнционалу он очень похож на SQL.

Сам запрос передается в строке запросов (query string) URL. Например:

/TimeSheets? $select=id, dateFrom, dateTo&$filter=approval&$expand=createdBy ($select=name)&$count=true

Метаданные

Вместе с каждым запросом возвращаются специальные поля с метаданными. Они могут быть полезны, например, для получения мета информации о запрашиваемой сущности. Дальше на них акцент делаться не будет.

Поэтому, многие запросы OData транслируются в SQL. У этого языка много ключевых слов, но рассмотрим основные. Для лучшего погружения представим ситуацию: нам нужно получить отчеты.

$select

$select позволяет нам выбрать только нужные поля.

Пример: нам нужно получить только отрезки времени отсчета таймшитов и имя того, кто его создал. Тогда следующий запрос:

/TimeSheets? $select=dateFrom, dateTo, id

{
    "@odata.context": "https://api.timetta.com/odata/$metadata#TimeSheets(dateFrom,dateTo,id)",
    "value": [
        {
            "dateFrom": "2021-10-04",
            "dateTo": "2021-10-10",
          
            "id": "00000000-0000-0000-0000-00000000"
        },
        {
            "dateFrom": "2021-11-22",
            "dateTo": "2021-11-28",
            "id": "00000000-0000-0000-0000-00000000"
        },
        {
            "dateFrom": "2022-06-27",
            "dateTo": "2022-07-03",
            "id": "00000000-0000-0000-0000-00000000"
        }
}

$filter

Прекрасно. Мы получили данные. Но что если какие-то табеля не были согласованы? Нужно убрать такие:

/TimeSheets?$select=dateFrom, dateTo, id&$filter=approvalInstanceId ne null

{
    "@odata.context": "https://api.timetta.com/odata/$metadata#TimeSheets(dateFrom,dateTo,id)",
    "value": [
        {
            "dateFrom": "2021-10-04",
            "dateTo": "2021-10-10",
            "id": "00000000-0000-0000-0000-00000000"
        },
        {
            "dateFrom": "2021-11-22",
            "dateTo": "2021-11-28",
            "id": "00000000-0000-0000-0000-00000000"
        }
}

Какой-то табель не был согласован! Хорошо, что мы обнаружили это.

Сам $filter принимает в себя логическое выражение. Доступные логические операции (bash-like):

  • eq (equal), ==: name eq 'За ноябрь'

  • ne (not equal), !=: approvalInstanceId ne null

  • gt (greater than), >: dueDate gt '2022-03-15'

  • ge (greater or equal), >=: dateFrom ge '2021-01-01'

  • lt (less than), <: age lt 18

  • le (less or equal), <=: ttl le 0

Эти выражения можно комбинировать с помощью скобок и:

  • and (логическое 'и'): (name eq 'За ноябрь') and (dateFrom ge '2021-01-01')

  • or (логическое отрицание): (dateFrom ge '2021-01-01') or (approvalInstanceId ne null)

  • not (отрицание): not isActive

Также есть и булевы литералы: true, false

$expand

Прекрасно. Мы отфильтровали данные и выбрали только те поля, которые нужны. Но теперь в отчете нужно получить и имя человека создавшего табель. Можно сделать несколько запросов: сначала массив табелей, затем для каждого — запрос на информацию о пользователе. Но это лишнее. Можно ведь сделать просто — добавить $expand:

/TimeSheets?$select=dateFrom, dateTo, id&$filter=approvalInstanceId ne null&$expand=user ($select=name)

{
    "@odata.context": "https://api.timetta.com/odata/$metadata#TimeSheets(dateFrom,dateTo,id)",
    "value": [
        {
            "dateFrom": "2021-10-04",
            "dateTo": "2021-10-10",
            "id": "00000000-0000-0000-0000-00000000",
          	"user": {
            	"name": "Владислав Иванов"
            }
        },
        {
            "dateFrom": "2021-11-22",
            "dateTo": "2021-11-28",
            "id": "00000000-0000-0000-0000-00000000",
          	"user": {
            	"name": "Кирилл Ильин"
            }
        }
}

Здесь продемонстрированы сразу 2 фичи:

  1. Возможность вставить сущность из навигационного свойства (у типа TimeSheet есть навигационное свойство user типа WP.User)

  2. Сделать подзапросы: здесь из всех возможных свойств нам нужно только имя. Для подзапросов есть ограничения — ключевые слова разделяются точкой с запятой, иначе парсинг строки запроса будет неверным. Например:

    /TimeSheets?$select=dateFrom, dateTo, id&$filter=approvalInstanceId ne null&$expand=user ($select=name;$expand=schedules) -дает нам не только имя пользователя, но и список его рабочих графиков. Если бы вместо точки с запятой был амперсанд, то получилось бы 2 параметра: $expand=user ($select=name и $expand=schedules)

$orderby

Вроде бы все прекрасно. Данные получаются, но мы хотим получать упорядоченные данные сразу. Нам поможет ключевое слово $orderby. Представим, что мы хотим сортировать табели сначала по дате начала по убыванию, а затем по дате конца по возрастанию (получим самые близкие к нам по дате, короткие по длительности):

/TimeSheets?$select=dateFrom, dateTo, id&$filter=approvalInstanceId ne null&$expand=user ($select=name)&$orderby=dateFrom desc, dateTo asc

{
    "@odata.context": "https://api.timetta.com/odata/$metadata#TimeSheets(dateFrom,dateTo,id,user(name))
    "value": [
        {
            "dateFrom": "2021-11-22",
            "dateTo": "2021-11-28",
            "id": "00000000-0000-0000-0000-00000000",
          	"user": {
            	"name": "Кирилл Ильин"
            }
        },
        {
          "dateFrom": "2021-10-04",
          "dateTo": "2021-10-10",
          "id": "00000000-0000-0000-0000-00000000",
          "user": {
            "name": "Владислав Иванов"
          }
        }
}

По умолчанию используется сортировка по возрастанию — asc

$top, $skip

Вот проблема! У нас слишком много данных. Как бы нам ограничить их прием? Для этого можно указать какое количество мы хотим принять с помощью $top. Тогда нам вернется не больше заданного количества:

/TimeSheets?$select=dateFrom, dateTo, id&$filter=approvalInstanceId ne null&$expand=user ($select=name)&$orderby=dateFrom desc, dateTo asc$top=5

А что если нужно еще и пропускать некоторое количество (для пагинации, например)? Тогда используем $skip:

/TimeSheets?$select=dateFrom, dateTo, id&$filter=approvalInstanceId ne null&$expand=user ($select=name)&$orderby=dateFrom desc, dateTo asc&$top=5&$skip=10

$count

Мы можем получить наши данные — прекрасно. А если я хочу знать только количество удовлетворяющих критерию? Или для пагинации? Нужно посчитать общее количество элементов. Здесь нам поможет $count. Он принимает булево значение: true — вернуть общее количество, false — не возвращать (по умолчанию)

/TimeSheets?$select=dateFrom, dateTo, id&$filter=approvalInstanceId ne null&$expand=user ($select=name)&$orderby=dateFrom desc, dateTo asc&$count=true

{
  "@odata.context": "https://api.timetta.com/odata/$metadata#TimeSheets(dateFrom,dateTo,id,user(name))
  "@odata.count": 2,  
  "value": [
        {
            "dateFrom": "2021-11-22",
            "dateTo": "2021-11-28",
            "id": "00000000-0000-0000-0000-00000000",
          	"user": {
            	"name": "Кирилл Ильин"
            }
        },
        {
          "dateFrom": "2021-10-04",
          "dateTo": "2021-10-10",
          "id": "00000000-0000-0000-0000-00000000",
          "user": {
            "name": "Владислав Иванов"
          }
        }
}

Функции, действия

Вот здесь начинается самое интересное — функции и действия (Functions, Actions). Теперь мы хотим получить табель за текущий период. Можно получить все табеля и отфильтровать их — сделать сложный запрос, а потом отфильтровать еще и результат. Это лишнее. Не проще ли использовать функцию:

/TimeSheets/Current

И все!

OData определяет функции (Function) и действия (Action)

Function — это операция над ресурсами, которая обязательно возвращает значение и не имеет сторонних эффектов.

Action — это операция, которая может изменить значение

PS. похоже на CQRS

Как же ими пользоваться? Для начала: операции могут быть привязанными и нет — требуется ли сущность для выполнения операции. Описание операции состоит из 

Функция из примера имеет следующее пределение:


  
  

Что это все значит:

  1. Name=«Current» — название функции, которую будем вызывать

  2. IsBound=«true» — функция привязана к конкретному типу. Т.е. вызвать ее из произвольного места нельзя

  3. «bindingParameter» — особый параметр означающий к какому типу функция применяется. Здесь применяется к типу Collection(WP.TimeSheet)(чем и является /TimeSheets)

  4. Возвращает тип TimeSheet

Если функция не принимает ничего, то скобок нет. Видели. А если функция принимает параметры? Тогда они указываются в элементе Parameter:


  
  
  
  
  

Эта функция принимает дополнительные параметры: userId, from, to (в данном случае они обязательные — Nullable=«false»). Передача параметров — как вызов функции, в скобках, причем все параметры именованные и вставляются через запятую (как в Python). Пример:

/Schedules/GetUserSchedule(userId=00000000-0000-0000-0000-00000000,from=2022-01-01,to=2022-02-01) — получение расписания для пользователя за весь январь

Что по Action? Разница в том, что действие может модифицировать данные и может не возвращать данные. Например:


	

Это действие устанавливает тип роли пользователя применяемой по умолчанию. Логично, что оно может модифицировать, а может и нет (если это тип уже был по умолчанию). Также он ничего не возвращает (разве что статусный код по которму и будет понятен результат). Как вызывать:

/Roles(00000000-0000-0000-0000-00000000)/SetAsDefault

Это привязанное действие и привязано оно к типу WP.Role, а значит к единственному элементу, а не к целой коллекции как было в предыдущем примере.

Пример действия, который что-то возвращает:


  
  
  

Модификация ресурсов

Для операций модификаций ресурсов используются HTTP методы: POST, PATCH, PUT, DELETE

Создание

Создание сущности = добавление в коллекцию. Для этого нужно сделать POST запрос с адресом коллекции и передать необходимые для создания параметры. Например, для создания нового департамента нужно сделать такой POST запрос:

POST /Departments

{    
    "code": "69",    
    "resourcePoolId": null,
    "name": "Какой-то департамент",        
    "leadDepartmentId": null
}

И в ответ получим:

{
    "@odata.context": "https://api.timetta.com/odata/$metadata#Departments/$entity",
    "code": "69",
    "resourceType": "Department",
    "resourcePoolId": null,
    "name": "Департамент",
    "rowVersion": "AAAAAAACG60=",
    "createdById": "00000000-0000-0000-0000-000000000000",
    "modifiedById": "00000000-0000-0000-0000-000000000000",
    "id": "11111111-1111-1111-1111-111111111111",
    "created": "2022-07-22T20:24:00.9318599+03:00",
    "modified": "2022-07-22T17:24:00.8776997Z",
    "isActive": true,
    "leadDepartmentId": null
}

Обновление

Для обновления используются 2 HTTP метода: PUT, PATCH (последний предпочтительнее). Если бы мы хотели обновить название только что созданного департамента на «Лучший департамент», то сделали такой запрос:

PATCH /Departments(11111111-1111-1111-1111-111111111111)

{
  "name": "Лучший департамент"
}

И в ответ — 204 No Content

При повторном запросе:

{
    "@odata.context": "https://api.timetta.com/odata/$metadata#Departments/$entity",
    "code": "69",
    "resourceType": "Department",
    "resourcePoolId": null,
    "name": "Лучший департамент",
    "rowVersion": "AAAAAAACG7A=",
    "createdById": "00000000-0000-0000-0000-000000000000",
    "modifiedById": "00000000-0000-0000-0000-000000000000",
    "id": "11111111-1111-1111-1111-111111111111",
    "created": "2022-07-22T20:24:00.9318599+03:00",
    "modified": "2022-07-24T05:21:09.764289Z",
    "isActive": true,
    "leadDepartmentId": null,
    "editAllowed": true,
    "deleteAllowed": true,
    "rolesEditAllowed": true
}

Название действительно изменилось. Также изменилось и поле «rowVersion» — для предотвращение параллельного обновления.

Но говорилось еще и о PUT. При использовании PUT нам нужно передавать ВСЮ сущность. Даже те поля, которые не обновляются (за исключением тех над которыми не имеем власти, например, rowVersion или modified). Тоже самое обновление, но с помощью PUT:

PUT /Departments(11111111-1111-1111-1111-111111111111)

{
    "code": "69",
    "resourceType": "Department",
    "resourcePoolId": null,
    "name": "Лучший департамент",
    "id": "11111111-1111-1111-1111-111111111111",
    "isActive": true,
    "leadDepartmentId": null,
    "editAllowed": true,
    "deleteAllowed": true,
    "rolesEditAllowed": true
}

Удаление

И для удаления остался последний метод — DELETE. Удаление через ID сущности. Удалим же наш департамент:

DELETE /Departments(11111111-1111-1111-1111-111111111111)

В ответ получим — 204 No Content. И при обращении по этому же ID получаем Not Found.

Подытожим

OData — мощный веб-фреймворк, ядром которого является управление ресурсами.

Для функционирования использует возможности HTTP: HTTP методы, HTTP заголовки, строки запроса, URL.

Сервис, использующий OData, определяет свою схему. По ней можно понять какую функциональность данный сервис предоставляет:

  • Типы данных:

    • Перечисления, Enum

    • Сложные типы, ComplexType

    • Сущности, EntityType

  • Их атрибуты

    • Свойства (и свойства свойств (Nullability, MaxLength)

    • Навигационные свойства

    • Специфичные для данного типа атрибуты: ключ для сущности, значения для перечисления

  • Коллекции: их название, доступ, содержащийся в них тип данных

  • Предопределенные функции и действия (Function, Action)

Сам протокол определяет SQL подобный язык запросов (передается в строке запроса) и позволяет управлять получаемым контентом с помощью ключевых слов:

  • $select — в выводе только указанные поля. (~ SELECT)

  • $filter — вывести ресурсы, удовлетворяющие предикату (~ WHERE)

  • $expand — включить другие ресурсы, на который ссылается (~JOIN)

  • $orderby — сортировка по полю. (~ORDER BY)

  • $top — ограничить вывод максимальным количеством. (~TAKE)

  • $skip — пропустить какое-то количество. (~SKIP, OFFSET)

  • $count — дополнительно подсчитать общее число (или удовлетворяющих предикату) сущностей. (~COUNT)

Напоследок

OData очень большой фреймворк. Одна статья не может покрыть его полностью. Но целью этого поста стало простое ознакомление с наиболее используемой (по мнению автора) функциональностью. Многие темы не были покрыты, как например ключевое слово запроса $compute или лямбда операций any/all. Если вы хотите исследовать эту тему дальше, то вот некоторый список ссылок от куда можно стартовать:

  • https://www.odata.org — сайт по OData. Здесь можно найти спецификацию, туториалы для новичков и продвинутых, полезные инструменты и так далее.

  • https://docs.microsoft.com/en-us/odata — раздел об OData созданный Microsoft. Здесь много туториалов как по самому OData, так и по инструментам связанным с ним. Много уделяется фреймворку Microsoft.OData.

  • https://services.odata.org/ — сервис, для обучения/тестирования/просто попробовать OData. Как пользоваться для Read-Only Service:

    1. Устанавливаю базовый URL https://services.odata.org/V3/OData/OData.svc/

    2. Мне удобнее получать результат в виде JSON. Поэтому выставляю заголовок Accept: application/json

    3. Получаю схему OData: https://services.odata.org/V3/OData/OData.svc/$metadata

    4. Изучаю полученную схему и делаю запросы.

    5. Для получения списка всех продуктов по схеме нужно делаю такой запрос: https://services.odata.org/V3/OData/OData.svc/Products

      Read-Only Northwind пользоваться по аналогии

Если нужно потренироваться и в модификации, то есть Read-Write. Для этого нужен ваш секретный ключ. Получить его можно, перейдя по ссылке Browse the Full Access (Read-Write) Service на указанном сайте. В браузере ваша строка заменится на строку с ключом. Либо, эту строка указывается в полученной схеме. Сам вид строки: https://services.odata.org/V3/(S(<секретный ключ>))/OData/OData.svc. А в запросах на создание (POST) нужно также указывать тип ресурса, который хотим создать в поле «odata.type» (там используется наследование, про которое не было рассказано). Например, для создания нового Product сделать вот такой запрос:

POST https://services.odata.org/V3/(S(j5lmqrfbgk1st4mmgrva1jtg))/OData/OData.svc/Products

{
   "odata.type" :"ODataDemo.Product",
   "Name": "Cottage cheese",
   "ID": 11,
   "Description": "Best cottage cheese",
   "ReleaseDate": "2021-12-31T23:59:59",
   "DiscontinuedDate": "2022-01-01T00:00:00",
   "Rating": 5,
   "Price": 123
}

Полезные инструменты:

  • Расширение VS Code для работы с OData.

  • Веб-приложение для визуализации и исследования OData, по ее схеме. Использование: в верхней части в поисковике выберите вариант 'Metadata URL'. Вбейте в поисковик URL и нажмите 'Get Details'.

Пример для Timetta. Как получить адрес схемы было в начале.Пример для Timetta. Как получить адрес схемы было в начале.

Триггером написания статьи было, то что автор не нашел «краш курсов» по OData и пришлось собирать знания по кусочкам. Если статья дала вам хороший старт, то значит проделанное было не зря.

© Habrahabr.ru