OCaml и RESTful JSON API с использованием Eliom

в 15:19, , рубрики: api, eliom, ocaml, ocsigen, rest api, web, Программирование, Разработка веб-сайтов, функциональное программирование

Привет! Представляю вашему вниманию перевод туториала RESTful JSON API using Eliom.

В этом туториале рассказывается, как создать простой, но полный REST API с использованием JSON в качестве формата сериализации.

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

Чтобы быть RESTful, наш интерфейс будет соответствовать следующим принципам:

  • URL-адреса и GET-параметры определяют ресурсы
  • Методы HTTP (GET, POST, PUT, DELETE) используются для определения действий
  • Действие GET безопасно (без побочных эффектов)
  • Действия PUT и DELETE являются идемпотентными
  • Запросы являются stateless (в период между запросами клиента никакая информация о состоянии клиента на сервере не хранится)

Имея это в виду, наша цель будет заключаться в реализации функций CRUD (Create, Read, Update, Delete) для обработки наших ресурсов. Мы хотим, чтобы следующие запросы были действительными:

GET http:// localhost/ вернет все доступные местоположения.

GET http:// localhost/ID вернет местоположение, связанное с ID.

POST http:// localhost/ID с содержимым:

{
      "description": "Paris",
      "coordinates": {
        "latitude": 48.8567,
        "longitude": 2.3508
      }
    }

сохранит это местоположение в базе данных.

PUT http:// localhost/ID, с некоторым содержимым, обновит местоположение, связанное с идентификатором.

DELETE http:// localhost/ID удалит местоположение, связанное с ID.

Зависимости

Предполагается что вы уже знакомы с Eliom, это нужно что бы понять туториал полностью. Этот туториал не является введением в Eliom.
Следующие браузерные расширения могут быть полезны для ручной проверки REST API:

Типы данных

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

Мы представляем координаты с десятичными градусами и используем библиотеку deriving-yojson для анализа и сериализации наших типов в JSON.

Мы используем выделенный тип ошибки, возвращаемый, когда что-то не так с запросом или с обработкой запроса.

Что касается базы данных, мы используем простую таблицу Ocsipersist.

type coordinates = {
  latitude : float;
  longitude : float;
} deriving (Yojson)

type location = {
  description : string option;
  coordinates : coordinates;
} deriving (Yojson)

(* List of pairs (identifier * location) *)
type locations =
  (string * location) list
    deriving (Yojson)

type error = {
  error_message : string;
} deriving (Yojson)

let db : location Ocsipersist.table =
  Ocsipersist.open_table "locations"

Определение служб

Во-первых, давайте определим общие параметры обслуживания:

  • path(путь) API: одинаковый для всех служб.
  • Параметр GET, который является необязательным идентификатором, указанным в качестве суффикса URL. Устанавливаем его как необязательный, чтобы мы могли отличать запросы GET для одного или всех ресурсов и возвращать подробную ошибку, если идентификатор отсутствует в запросах POST, PUT и DELETE. Альтернативой будет использование двух служб на одном пути (одна с id, а другая без).

let path = []

let get_params =
  Eliom_parameter.(suffix (neopt (string "id")))

Следующий шаг — определить наши API службы. Мы определяем четыре из них с одним и тем же путем, используя четыре метода HTTP в нашем распоряжении:

  • Метод GET будет использоваться для доступа к базе данных, для любого из ресурсов, если не указан идентификатор, или только для единственного ресурса. Если ресурс не будет соответствовать идентификатору, будет возвращена ошибка.
  • Метод POST будет использоваться для создания нового ресурса (или его обновления, если он уже существует). Мы устанавливаем один параметр POST: Eliom_parameter.raw_post_data, чтобы получить необработанный JSON и обойти блокировку параметров после декодирования.
  • Метод PUT будет использоваться для обновления существующего ресурса. Если ресурс не будет соответствовать идентификатору, будет возвращена ошибка. Нам не нужно определять параметр POST, PUT-службы принимают значение Eliom_parameter.raw_post_data как содержимое по умолчанию.
  • Метод DELETE будет использоваться для удаления существующего ресурса. Если ресурс не будет соответствовать идентификатору, будет возвращена ошибка.

let read_service =
  Eliom_service.Http.service
    ~path
    ~get_params
    ()

let create_service =
  Eliom_service.Http.post_service
    ~fallback:read_service
    ~post_params:Eliom_parameter.raw_post_data
    ()

let update_service =
  Eliom_service.Http.put_service
    ~path
    ~get_params
    ()

let delete_service =
  Eliom_service.Http.delete_service
    ~path
    ~get_params
    ()

Обработчики

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

Поскольку мы используем функцию низкого уровня Eliom_registration.String.send для отправки нашего ответа, мы переносим его на три специализированные функции: send_json, send_error и send_success (эта отправляет только код состояния 200 OK без какого-либо содержимого).

Другая функция помогает нам проверить, что полученный тип содержимого является ожидаемым, сопоставляя его с MIME-типом. В нашем примере проверим, что мы получаем JSON.

Функция read_raw_content извлекает указанное или стандартное length количество символов из потока Ocsigen raw_content.

let json_mime_type = "application/json"

let send_json ~code json =
  Eliom_registration.String.send ~code (json, json_mime_type)

let send_error ~code error_message =
  let json = Yojson.to_string<error> { error_message } in
  send_json ~code json

let send_success () =
  Eliom_registration.String.send ~code:200 ("", "")

let check_content_type ~mime_type content_type =
  match content_type with
  | Some ((type_, subtype), _)
      when (type_ ^ "/" ^ subtype) = mime_type -> true
  | _ -> false

let read_raw_content ?(length = 4096) raw_content =
  let content_stream = Ocsigen_stream.get raw_content in
  Ocsigen_stream.string_of_stream length content_stream

Затем мы определяем наши обработчики для выполнения необходимых действий и возврата ответа.

Обработчики POST и PUT будут считывать содержимое исходного контента в JSON и использовать Yojson для преобразования его в наши типы.

В ответах мы используем коды состояния HTTP, с значениями:

  • 200 (OK): запрос выполнен успешно.
  • 400 (неверный запрос): что-то не так с запросом (отсутствующий параметр, ошибка синтаксического анализа ...).
  • 404 (Не найдено): ресурс не соответствует предоставленному идентификатору.

Обработчик GET либо возвращает одно местоположение, если предоставлен идентификатор, иначе список всех существующих местоположений.

let read_handler id_opt () =
  match id_opt with
  | None ->
    Ocsipersist.fold_step
      (fun id loc acc -> Lwt.return ((id, loc) :: acc)) db []
    >>= fun locations ->
    let json = Yojson.to_string<locations> locations in
    send_json ~code:200 json
  | Some id ->
    catch (fun () ->
        Ocsipersist.find db id >>= fun location ->
        let json = Yojson.to_string<location> location in
        send_json ~code:200 json)
    (function
      | Not_found ->
        (* [id] hasn't been found, return a "Not found" message *)
        send_error ~code:404 ("Resource not found: " ^ id))

Затем давайте создадим общую функцию для обработчиков POST и PUT, которые имеют очень похожее поведение. Единственное различие заключается в том, что запрос PUT с несуществующим идентификатором будет возвращать ошибку(таким образом, он будет только принимать запросы на обновление и отклонять запросы на создание), тогда как тот же запрос с методом POST будет успешным (будет создано новое местоположение, связанное с идентификатором).

let edit_handler_aux ?(create = false) id_opt (content_type, raw_content_opt) =
  if not (check_content_type ~mime_type:json_mime_type content_type) then
    send_error ~code:400 "Content-type is wrong, it must be JSON"
  else
    match id_opt, raw_content_opt with
    | None, _ ->
      send_error ~code:400 "Location identifier is missing"
    | _, None ->
      send_error ~code:400 "Body content is missing"
    | Some id, Some raw_content ->
      read_raw_content raw_content >>= fun location_str ->
      catch (fun () ->
          (if create then
            Lwt.return_unit
          else
            Ocsipersist.find db id >>= fun _ -> Lwt.return_unit)
          >>= fun () ->
          let location = Yojson.from_string<location> location_str in
          Ocsipersist.add db id location >>= fun () ->
          send_success ())
      (function
        | Not_found ->
          send_error ~code:404 ("Location not found: " ^ id)
        | Deriving_Yojson.Failed ->
          send_error ~code:400 "Provided JSON is not valid")

let create_handler id_opt content =
  edit_handler_aux ~create:true id_opt content

let update_handler id_opt content =
  edit_handler_aux ~create:false id_opt content

Для удаления местоположений нужен четвертый обработчик:

let delete_handler id_opt _ =
  match id_opt with
  | None ->
    send_error ~code:400 "An id must be provided to delete a location"
  | Some id ->
    Ocsipersist.remove db id >>= fun () ->
    send_success ()

Регистрация служб

Наконец, мы регистрируем службы с помощью модуля Eliom_registration.Any, чтобы иметь полный контроль над отправляемым ответом. Таким образом, мы сможем отправить соответствующий код статуса HTTP в зависимости от того, что происходит во время обработки запроса (ошибка синтаксического анализа, ресурс не найден ...), как это показано выше при определении обработчиков.

let () =
  Eliom_registration.Any.register read_service read_handler;
  Eliom_registration.Any.register create_service create_handler;
  Eliom_registration.Any.register update_service update_handler;
  Eliom_registration.Any.register delete_service delete_handler;
  ()

Полный исходник

Всё что у нас получилось в итоге


open Lwt

(**** Data types ****)

type coordinates = {
  latitude : float;
  longitude : float;
} deriving (Yojson)

type location = {
  description : string option;
  coordinates : coordinates;
} deriving (Yojson)

(* List of pairs (identifier * location) *)
type locations =
  (string * location) list
    deriving (Yojson)

type error = {
  error_message : string;
} deriving (Yojson)

let db : location Ocsipersist.table =
  Ocsipersist.open_table "locations"


(**** Services ****)

let path = []

let get_params =
  Eliom_parameter.(suffix (neopt (string "id")))

let read_service =
  Eliom_service.Http.service
    ~path
    ~get_params
    ()

let create_service =
  Eliom_service.Http.post_service
    ~fallback:read_service
    ~post_params:Eliom_parameter.raw_post_data
    ()

let update_service =
  Eliom_service.Http.put_service
    ~path
    ~get_params
    ()

let delete_service =
  Eliom_service.Http.delete_service
    ~path
    ~get_params
    ()

(**** Handler helpers ****)

let json_mime_type = "application/json"

let send_json ~code json =
  Eliom_registration.String.send ~code (json, json_mime_type)

let send_error ~code error_message =
  let json = Yojson.to_string<error> { error_message } in
  send_json ~code json

let send_success () =
  Eliom_registration.String.send ~code:200 ("", "")

let check_content_type ~mime_type content_type =
  match content_type with
  | Some ((type_, subtype), _)
      when (type_ ^ "/" ^ subtype) = mime_type -> true
  | _ -> false

let read_raw_content ?(length = 4096) raw_content =
  let content_stream = Ocsigen_stream.get raw_content in
  Ocsigen_stream.string_of_stream length content_stream

(**** Handlers ****)

let read_handler id_opt () =
  match id_opt with
  | None ->
    Ocsipersist.fold_step
      (fun id loc acc -> Lwt.return ((id, loc) :: acc)) db []
    >>= fun locations ->
    let json = Yojson.to_string<locations> locations in
    send_json ~code:200 json
  | Some id ->
    catch (fun () ->
        Ocsipersist.find db id >>= fun location ->
        let json = Yojson.to_string<location> location in
        send_json ~code:200 json)
    (function
      | Not_found ->
        (* [id] hasn't been found, return a "Not found" message *)
        send_error ~code:404 ("Resource not found: " ^ id))

let edit_handler_aux ?(create = false) id_opt (content_type, raw_content_opt) =
  if not (check_content_type ~mime_type:json_mime_type content_type) then
    send_error ~code:400 "Content-type is wrong, it must be JSON"
  else
    match id_opt, raw_content_opt with
    | None, _ ->
      send_error ~code:400 "Location identifier is missing"
    | _, None ->
      send_error ~code:400 "Body content is missing"
    | Some id, Some raw_content ->
      read_raw_content raw_content >>= fun location_str ->
      catch (fun () ->
          (if create then
            Lwt.return_unit
          else
            Ocsipersist.find db id >>= fun _ -> Lwt.return_unit)
          >>= fun () ->
          let location = Yojson.from_string<location> location_str in
          Ocsipersist.add db id location >>= fun () ->
          send_success ())
      (function
        | Not_found ->
          send_error ~code:404 ("Location not found: " ^ id)
        | Deriving_Yojson.Failed ->
          send_error ~code:400 "Provided JSON is not valid")

let create_handler id_opt content =
  edit_handler_aux ~create:true id_opt content

let update_handler id_opt content =
  edit_handler_aux ~create:false id_opt content

let delete_handler id_opt _ =
  match id_opt with
  | None ->
    send_error ~code:400 "An id must be provided to delete a location"
  | Some id ->
    Ocsipersist.remove db id >>= fun () ->
    send_success ()

(* Register services *)

let () =
  Eliom_registration.Any.register read_service read_handler;
  Eliom_registration.Any.register create_service create_handler;
  Eliom_registration.Any.register update_service update_handler;
  Eliom_registration.Any.register delete_service delete_handler;
  ()

Источник: RESTful JSON API using Eliom

От переводчика

Хотелось что бы сообщество OCaml было больше и росло, а сам язык развивался быстрее, язык хороший, а местами даже лучше мэйнстримовых языков, вот несколько его плюсов: он собирается в натив, синтаксис у него довольно лаконичен и понятен(не сразу, но как по мне он легче дается, чем Haskell, но вообще это вкусовщина), также довольно удобная система типов и хорошее ООП конечно. Если этот перевод кому-то пригодился или заставил взглянуть на OCaml и его экосистему, попробовать его, то я могу делать еще переводы или авторские статьи. Об ошибках прошу сообщать в личку.

P.S.:
Вводные статьи про OCaml и Ocsigen на Хабре, с которыми возможно стоит ознакомиться новичкам:

но конечно лучше ознакомиться с официальными мануалами, потому что статьям выше по 6-7 лет, какие-то основы из них извлечь конечно можно(а с учетом вялого развития языка вероятность извлечь базовые знания и не подорваться, стремится к 100%), но я не ручаюсь, что на данный момент там все правильно, особенно в статье про Oscigen. Всем добра и приятного пути в развитии.

Автор: nbytes

Источник

Поделиться

* - обязательные к заполнению поля