image


Введение


Во Вк есть наборы стикеров, некоторые из которых даже бесплатные. Но во Вк нет ни какого публичного API для использования данного функционала на сторонних сайтах. Задачка состоит в том, чтобы используя функциональный язык Elixir написать расширение над местом хранения стикеров во Вк в виде API.


По моему мнению имена методов, и параметры, которые они принимали были бы следующими. Общим пространством имён для коллекции API методов для работы со стикерами было бы ключевое слово stickers, а сами методы возможно выглядели бы так:


stickers.get — со следующими параметрами: pack_ids, pack_id, fields;
stickers.getById — со следующими параметрами: sticker_ids, sticker_id, fields.


Так как нет возможности создавать или редактировать стикеры, которые есть во Вк, данное API будет иметь только read-only методы. Честно, сложно угадывать, и не хочется подражать разработчикам социальной сети, по этому ограничусь только придумыванием имён методов. И не буду реализовывать API в стиле Вк, хоть это бы и добавило общей идентичности расширению.


Вот такие методы буду реализовывать для работы со стикерами:


Методы для наборов:


GET /packs
GET /packs/{id}
GET /packs/{id}/stickers

Методы для стикеров:


GET /stickers
GET /stickers/{id}
GET /stickers/{id}/pack

Реализация


Как написано выше, языком для написания программирования выбран Elixir. Базой данных в проекте будет выступать PostgreSQL и для взаимодействия с ней будут использованы Postgrex и Ecto. В качестве web-сервера будет использован Cowboy. За сериализацию данных в json-формат будет отвечать Poison. Вся поставленная задача довольно не объёмная и не сложная, по этому Phoenix использоваться не будет.


Для создания нового приложения используется команда mix new api_vk_stickers, она создаст базовую структуру, на основе которой будет строится расширение для API Вк.


Первым делам следует отредактировать файл mix.exs, который содержит базовую информацию о приложении и список используемых внешних зависимостей:


# mix.exs

defmodule ApiVkStickers.Mixfile do
  use Mix.Project

  # ...

  defp deps do
    [{:postgrex, "~> 0.13"},
     {:ecto, "~> 2.1.1"},
     {:cowboy, "~> 1.0.4"},
     {:plug, "~> 1.1.0"},
     {:poison, "~> 3.0"}]
  end
end

После редактирования списка зависимостей необходимо их все установить, для этого предназначена команда mix deps.get.


Теперь приступим к написанию логики самого расширения. Структура проекта будет следующая:


models/
  pack.ex
  sticker.ex
decorators/
  pack_decorator.ex
  sticker_decorator.ex
encoders/
  packs_encoder.ex
  stickers_encoder.ex
finders/
  packs_finder.ex
  stickers_finder.ex
parsers/
  ids_param_parser.ex
controllers/
  packs_controller.ex
  stickers_controller.ex
router.ex

models


Модели создаются с использованием модуля Ecto.Schema. В модели Pack вместе с полем title будет ещё несколько дополнительных не обязательных полей.


Структура модели задаётся с помощью выражения schema/2, как аргумент она принимает имя источника, то есть название таблицы. Поля задаются в теле schema/2 с помощью выражения filed/3. filed/3 принимает название поля, тип поля (по умолчанию :string) и дополнительные не обязательные функции (по умолчанию []).


Для определения связи один-ко-многим используется выражение has_many/3.


# pack.ex

defmodule ApiVkStickers.Pack do
  use Ecto.Schema

  schema "packs" do
    field :title
    field :author
    field :slug

    has_many :stickers, ApiVkStickers.Sticker
  end
end

Для противоположной связи один-к-одному предназначено выражение belongs_to/3.


Код Sticker
# sticker.ex

defmodule ApiVkStickers.Sticker do
  use Ecto.Schema

  schema "stickers" do
    field :src, :map, virtual: true

    belongs_to :pack, ApiVkStickers.Pack
  end
end

decorators


В Эликсире по понятным причинам объектов нет, но всё же логика расширения моделей будет размещена в модулях с суффиксом _decorator. API на равне с атрибутами полученными из базы данных также будут возвращать несколько дополнительных атрибутов. Для наборов это будет коллекция обложек в двух размерах и url места, где можно добавить себе данный набор во Вк.


# pack_decorator.ex

defmodule ApiVkStickers.PackDecorator do
  @storage_url "https://vk.com/images/store/stickers"
  @shop_url "https://vk.com/stickers"

  def source_urls(pack) do
    id = pack.id

    %{small: "#{@storage_url}/#{id}/preview1_296.jpg",
      large: "#{@storage_url}/#{id}/preview1_592.jpg"}
  end

  def showcase_url(pack) do
    "#{@shop_url}/#{pack.slug}"
  end
end

Для стикеров дополнительным атрибутами будет коллекция адресов картинок в четырёх вариациях.


Код StickerDecorator
# sticker_decorator.ex

defmodule ApiVkStickers.StickerDecorator do
  @storage_url "https://vk.com/images/stickers"

  def source_urls(sticker) do
    id = sticker.id

    %{thumb: "#{@storage_url}/#{id}/64.png",
      small: "#{@storage_url}/#{id}/128.png",
      medium: "#{@storage_url}/#{id}/256.png",
      large: "#{@storage_url}/#{id}/512.png"}
  end
end

encoders


Сериализаторы будут ответственны за преобразование атрибутов в json-формат. Первым делом из модели будет создан ассоциативный массив с базовыми атрибутами, а затем в него будут добавлены экстра атрибуты полученные из декораторов. Последним шагом будет преобразование массива в JSON с помощью модуля Poison.Encoder.Map. Модуль PacksEncoder будет иметь один публичный метод call/1.


# packs_encoder.ex

defmodule ApiVkStickers.PacksEncoder do
  alias ApiVkStickers.PackDecorator

  defimpl Poison.Encoder, for: ApiVkStickers.Pack do
    def encode(pack, options) do
      Map.take(pack, [:id, :title, :author])
      |> Map.put(:source_urls, PackDecorator.source_urls(pack))
      |> Map.put(:showcase_url, PackDecorator.showcase_url(pack))
      |> Poison.Encoder.Map.encode(options)
    end
  end

  def call(stickers) do
    Poison.encode!(stickers)
  end
end

Сериализатор для стикеров будет идентичен.


Код StickersEncoder
# stickers_encoder.ex

defmodule ApiVkStickers.StickersEncoder do
  alias ApiVkStickers.StickerDecorator

  defimpl Poison.Encoder, for: ApiVkStickers.Sticker do
    def encode(sticker, options) do
      Map.take(sticker, [:id, :pack_id])
      |> Map.put(:source_urls, StickerDecorator.source_urls(sticker))
      |> Poison.Encoder.Map.encode(options)
    end
  end

  def call(stickers) do
    Poison.encode!(stickers)
  end
end

finders


Для того чтобы не хранить логику запросов к базе данных в контроллерах, будут использованы файндеры (простите, искатели). Их будет также два, по количеству моделей. Файндер по наборам будет иметь три базовые функции: all/1 — получение коллекции наборов, one/1 — получение одного набора и by_ids/1 — получение коллекции наборов согласно переданным id.


# packs_finder.ex

defmodule ApiVkStickers.PacksFinder do
  import Ecto.Query

  alias ApiVkStickers.{Repo, Pack}

  def all(query \\ Pack) do
    Repo.all(from p in query, order_by: p.id)
  end

  def one(id) do
    Repo.get(Pack, id)
  end

  def by_ids(ids) do
    all(from p in Pack, where: p.id in ^ids)
  end
end

Похожими функциями будет обладать файндер по стикерам, за исключением третьей функции by_pack_id/1, которая возвращает коллекцию стикеров не по их id, а по их pack_id.


Код StickersFinder
# stickers_finder.ex

defmodule ApiVkStickers.StickersFinder do
  import Ecto.Query

  alias ApiVkStickers.{Repo, Sticker}

  def all(query \\ Sticker) do
    Repo.all(from s in query, order_by: s.id)
  end

  def one(id) do
    Repo.get(Sticker, id)
  end

  def by_pack_ids(pack_ids) do
    all(from s in Sticker, where: s.pack_id in ^pack_ids)
  end
end

parsers


Данный сервис необходим из-за того, что не была познана практика передачи параметров в url GET-запроса таким образом, чтобы Plug автоматически представлял мне массив. И вообще как-то создавал для переданного набора id какую-то переменную, без указания принимаемых параметров в выражении get/3 модуля Plug.Router.


# ids_param_parser.ex

defmodule ApiVkStickers.IdsParamParser do
  def call(query_string, param_name \\ "ids") do
    ids = Plug.Conn.Query.decode(query_string)[param_name]

    if ids do
      String.split(ids, ",")
    end
  end
end

controllers


Контроллеры будут на основе модуля Plug.Router, DSL которого многим напомнит фреймворк Sinatra. Но прежде чем приступить к самим контроллерам, необходимо собрать модуль который будет отвечать за маршруты.


Код router.ex
defmodule ApiVkStickers.Router do
  use Plug.Router

  plug Plug.Logger
  plug :match
  plug :dispatch

  forward "/packs", to: ApiVkStickers.PacksController
  forward "/stickers", to: ApiVkStickers.StickersController

  match _ do
    conn
    |> put_resp_content_type("application/json")
    |> send_resp(404, ~s{"error":"not found"}))
  end
end

Контроллеры по сути дела тоже будут такими же маршрутными модулями, но в душе есть вера, в то, что размещение этих модулей в папку controllers было правильным решением.


# packs_controller

defmodule ApiVkStickers.PacksController do
  # ...

  get "/" do
    ids = IdsParamParser.call(conn.query_string)

    packs = if ids do
              PacksFinder.by_ids(ids)
            else
              PacksFinder.all
            end
      |> PacksEncoder.call

    send_json_resp(conn, packs)
  end

  get "/:id" do
    pack = PacksFinder.one(id)
           |> PacksEncoder.call

    send_json_resp(conn, pack)
  end

  get "/:id/stickers" do
    stickers = StickersFinder.by_pack_ids([id])
               |> StickersEncoder.call

    send_json_resp(conn, stickers)
  end

  # ...
end

Код StickersController
# stickers_controller

defmodule ApiVkStickers.StickersController do
  # ...

  get "/" do
    pack_ids = IdsParamParser.call(conn.query_string, "pack_ids")

    stickers = if pack_ids do
                 StickersFinder.by_pack_ids(pack_ids)
               else
                 StickersFinder.all
               end
      |> StickersEncoder.call

    send_json_resp(conn, stickers)
  end

  get "/:id" do
    sticker = StickersFinder.one(id)
              |> StickersEncoder.call

    send_json_resp(conn, sticker)
  end

  get "/:id/pack" do
    sticker = StickersFinder.one(id)

    pack = PacksFinder.one(sticker.pack_id)
           |> PacksEncoder.call

    send_json_resp(conn, pack)
  end

  # ...
end

Результат


$ curl -X GET --header 'Accept: application/json' 'http://localhost:4000/packs'
[{"title":"Спотти", "source_urls":{"small":"https://vk.com/images/store/stickers/1/preview1_296.jpg", "large":"https://vk.com/images/store/stickers/1/preview1_592.jpg"}, "showcase_url":"https://vk.com/stickers/spotty", "id":1,"author":"Андрей Яковенко"}, {"title":"Персик", "source_urls":{"small":"https://vk.com/images/store/stickers/2/preview1_296.jpg", "large":"https://vk.com/images/store/stickers/2/preview1_592.jpg"}, "showcase_url":"https://vk.com/stickers/persik", "id":2,"author":"Елена Савченко"}, {"title":"Смайлы", "source_urls":{"small":"https://vk.com/images/store/stickers/3/preview1_296.jpg", "large":"https://vk.com/images/store/stickers/3/preview1_592.jpg"}, "showcase_url":"https://vk.com/stickers/smilies", "id":3,"author":"Елена Савченко"}, {"title":"Фруктовощи", "source_urls":{"small":"https://vk.com/images/store/stickers/4/preview1_296.jpg", "large":"https://vk.com/images/store/stickers/4/preview1_592.jpg"}, "showcase_url":"https://vk.com/stickers/fruitables", "id":4,"author":"Андрей Яковенко"}]

$ curl -X GET --header 'Accept: application/json' 'http://localhost:4000/packs/?ids=2,3'
[{"title":"Персик", "source_urls":{"small":"https://vk.com/images/store/stickers/2/preview1_296.jpg", "large":"https://vk.com/images/store/stickers/2/preview1_592.jpg"},"showcase_url":"https://vk.com/stickers/persik", "id":2,"author":"Елена Савченко"}, {"title":"Смайлы", "source_urls":{"small":"https://vk.com/images/store/stickers/3/preview1_296.jpg", "large":"https://vk.com/images/store/stickers/3/preview1_592.jpg"},"showcase_url":"https://vk.com/stickers/smilies", "id":3,"author":"Елена Савченко"}]

$ curl -X GET --header 'Accept: application/json' 'http://localhost:4000/packs/1'
{"title":"Спотти", "source_urls":{"small":"https://vk.com/images/store/stickers/1/preview1_296.jpg", "large":"https://vk.com/images/store/stickers/1/preview1_592.jpg"}, "showcase_url":"https://vk.com/stickers/spotty", "id":1,"author":"Андрей Яковенко"}

$ curl -X GET --header 'Accept: application/json' 'http://localhost:4000/packs/1/stickers'
[{"source_urls":{"thumb":"https://vk.com/images/stickers/1/64.png", "small":"https://vk.com/images/stickers/1/128.png", "medium":"https://vk.com/images/stickers/1/256.png", "large":"https://vk.com/images/stickers/1/512.png"}, "pack_id":1,"id":1},...,{"source_urls":{"thumb":"https://vk.com/images/stickers/48/64.png", "small":"https://vk.com/images/stickers/48/128.png", "medium":"https://vk.com/images/stickers/48/256.png", "large":"https://vk.com/images/stickers/48/512.png"}, "pack_id":1,"id":48}]

$ curl -X GET --header 'Accept: application/json' 'http://localhost:4000/stickers'
[{"source_urls":{"thumb":"https://vk.com/images/stickers/1/64.png", "small":"https://vk.com/images/stickers/1/128.png", "medium":"https://vk.com/images/stickers/1/256.png", "large":"https://vk.com/images/stickers/1/512.png"}, "pack_id":1,"id":1}, {"source_urls":{"thumb":"https://vk.com/images/stickers/2/64.png", "small":"https://vk.com/images/stickers/2/128.png", "medium":"https://vk.com/images/stickers/2/256.png", "large":"https://vk.com/images/stickers/2/512.png"}, "pack_id":1,"id":2}, {"source_urls":{"thumb":"https://vk.com/images/stickers/3/64.png", "small":"https://vk.com/images/stickers/3/128.png", "medium":"https://vk.com/images/stickers/3/256.png", "large":"https://vk.com/images/stickers/3/512.png"}, "pack_id":1,"id":3},...,{"source_urls":{"thumb":"https://vk.com/images/stickers/167/64.png", "small":"https://vk.com/images/stickers/167/128.png", "medium":"https://vk.com/images/stickers/167/256.png", "large":"https://vk.com/images/stickers/167/512.png"}, "pack_id":4,"id":167}, {"source_urls":{"thumb":"https://vk.com/images/stickers/168/64.png", "small":"https://vk.com/images/stickers/168/128.png", "medium":"https://vk.com/images/stickers/168/256.png", "large":"https://vk.com/images/stickers/168/512.png"}, "pack_id":4,"id":168}]

$ curl -X GET --header 'Accept: application/json' 'http://localhost:4000/stickers/?pack_ids=2,3'
[{"source_urls":{"thumb":"https://vk.com/images/stickers/49/64.png", "small":"https://vk.com/images/stickers/49/128.png", "medium":"https://vk.com/images/stickers/49/256.png", "large":"https://vk.com/images/stickers/49/512.png"},"pack_id":2,"id":49}, ..., {"source_urls":{"thumb":"https://vk.com/images/stickers/128/64.png", "small":"https://vk.com/images/stickers/128/128.png", "medium":"https://vk.com/images/stickers/128/256.png", "large":"https://vk.com/images/stickers/128/512.png"},"pack_id":3,"id":128}]

$ curl -X GET --header 'Accept: application/json' 'http://localhost:4000/stickers/1'
{"source_urls":{"thumb":"https://vk.com/images/stickers/1/64.png", "small":"https://vk.com/images/stickers/1/128.png", "medium":"https://vk.com/images/stickers/1/256.png", "large":"https://vk.com/images/stickers/1/512.png"}, "pack_id":1,"id":1}

$ curl -X GET --header 'Accept: application/json' 'http://localhost:4000/stickers/1/pack'
{"title":"Спотти", "source_urls":{"small":"https://vk.com/images/store/stickers/1/preview1_296.jpg", "large":"https://vk.com/images/store/stickers/1/preview1_592.jpg"}, "showcase_url":"https://vk.com/stickers/spotty", "id":1,"author":"Андрей Яковенко"}

Послесловие


Из проекта можно убрать PostgreSQL. В таком случае все данные о наборах стикеров будут храниться в коде включая данные об интервале принадлежащих им стикеров. Проект не сильно упростится, но в скорость базы данных вы уже не уткнётесь точно.


  1. Если вам интересен функциональный язык программирования Elixir или вы просто сочувствующий то советую вам присоединиться к Telegram-каналу https://telegram.me/proelixir про Elixir.
  2. У отечественного Elixir сообщества начинает появляться единая площадка в лице проекта Wunsh.ru. Сейчас ребята во всю пишут новую версию сайта. Но уже у них есть подписка на рассылку. В ней нет ничего нелегального, раз в недельку будет приходить письмо с подборкой статей про Elixir на русском языке.

Если вам интересна тема создания своих приложений на Elixir, могу посоветовать статью: Создание Elixir-приложения на примере. От инициализации до публикации https://habrahabr.ru/post/317444/.

Поделиться с друзьями
-->

Комментарии (21)


  1. Acuna
    05.01.2017 18:04

    А конкретно при выборе Эликсира для разработки какая цель преследовалась? Просто в плане сопровождения далеко не самый популярный ЯП.


    1. Folklore
      05.01.2017 19:12

      У Elixir есть много шансов стать языком программирования ближайшего десятилетия. Например Elixir благодаря Erlang VM (BEAM) по максимуму будет использовать все ядра вашего железа! Работа с памятью в Elixir тоже устроена на ура! И отказоустойчивость, которой знаменит Erlang, тоже достижима в Elixir.


      А ещё я обещал одному HR, что напишу API на Elixir. Обещания нужно исполнять :)


      1. JC_IIB
        06.01.2017 19:33

        У меня странное ощущение, но я действительно не понимаю, зачем нужен Elixir, если есть преотличнейший Erlang? Все киллер-фичи Эликсира, в том числе и перечисленные Вами выше — цельнотянуты из Эрланга. Зачем-то приделали мутабельность данных и модный ruby/python-like синтаксис. Нет, из буханки хлеба можно сделать троллейбус, но зачем?


        1. crmMaster
          07.01.2017 13:02
          -1

          На эрланге попрограммируете, и поймете для чего нужен элексир.
          Эрланг — чистый функциональный язык, и не скрывает этого, что накладывает на программиста чрезмерно много требований к его квалификации, Элексир по-проще намного.


          1. JC_IIB
            07.01.2017 20:56

            На эрланге попрограммируете
            Почему вы думаете, что я не программировал на Erlang?
            Эрланг — чистый функциональный язык
            Joe Armstrong (один из создателей Erlang) как-то назвал его «the most object-oriented language». Но бог с ним, с Джо, один черт Erlang — НЕ чисто функциональный язык.
            накладывает на программиста чрезмерно много требований к его квалификации
            Я всю свою долгую жизнь думал, что профессия программиста накладывает много требований к квалификации, если это программист, а не манки-копипастер со StackOverflow. И «чрезмерно» тут не бывает, no pain — no gain.
            Элексир по-проще намного.

            У Erlang достаточно низкий порог вхождения. Впрочем, Elixir это по большому счету даже не язык. Это синтаксический сахар к Erlang. И он не нужен.


        1. Source
          08.01.2017 01:39

          Основное отличие Elixir от Erlang — это возможности метапрограммирования в стиле Lisp, ну и более удобный тулинг. Остальное — по сути либо следствия, либо мелочи.
          P.S. Мутабельности данных в Elixir нет, есть ребиндинг переменных, но это совсем не то же самое.


        1. thousandsofthem
          09.01.2017 13:14
          +1

          Навскидку:


          • Более привычный "хипстерам" синтаксис == много новых людей == развитие экосистемы
          • Честные utf-8 строки везде
          • Консистентная стандартная библиотека
          • Нормальный менеджер зависимостей
          • Mix
          • Достаточно удобное метапрограммирование


      1. Acuna
        07.01.2017 17:33

        Огого! Ну да, пожалуй из-за Эрланга стоит присмотреться к нему повнимательнее, благодарю за введение) Ну а для веб-разработки (именно сайтов) он как, годится, или как для Питона без Джанго какого-нибудь лучше не начинать?


        1. JC_IIB
          07.01.2017 21:01

          Для web-разработки попробуйте, например, посмотреть на Zotonic. Это Erlang :)


          1. Source
            08.01.2017 01:42

            Тогда уж N2O, а то Zotonic больше на аналог Wordpress тянет.


        1. Source
          08.01.2017 01:43

          Ну а для веб-разработки (именно сайтов) он как, годится, или как для Питона без Джанго какого-нибудь лучше не начинать?

          Для Elixir есть Phoenix.


  1. kafeman
    05.01.2017 20:16
    +1

    Простите, из статьи так и не понял: зачем это нужно?


    1. Folklore
      05.01.2017 20:55

      Одна из целей, популяризация функционального языка программирования Elixir.


      1. kafeman
        05.01.2017 22:33
        +1

        Я не про язык, зачем нужен сторонний read-only API для стикеров какой-то соц. сети? Или это у вас просто «Hello World» такой?


        1. Folklore
          06.01.2017 20:23

          Да, для меня это точно "Hello World" на Elixir.


        1. Acuna
          07.01.2017 17:36
          +1

          Ну во первых, сеть далеко не «какая-то», будем честны) А во вторых — вроде бы как автор объяснил в самом начале, что как такого API для доступа конкретно к стикерам эта «какая-то» соц. сеть не предоставляет. Или я не понял вопроса?

          P. S. А «Hello World!» знатный получился)


          1. kafeman
            07.01.2017 20:43

            Ну вот видите, если не предоставляет, значит создатели тоже не понимают, зачем он нужен.

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

            «Hello World» — я не про сложность, а про то, что это на практике никому не нужно.


            1. Acuna
              11.01.2017 20:16

              Ну у создателей, как минимум, могли просто не дойти до этого руки, ибо и других дел хватает, кроме как писать API для пары картинок, а во вторых — и правда возможны проблемы с авторскими правами, тут Вы правы.

              А про знатность имел ввиду, что труд автора действительно заслуживает внимания, ибо по сути он показал процесс разработки полноценного приложения на Эрланге по сути, плюс он и сам сказал, что это не более чем очередной «Hello World», ибо в разработке пректика играет ключевую роль, поэтому приходится разрабатывать то, что скорее всего сгодится только для оттачивания навыков. Или, как в случае с поделкой автора, она может быть и полезна.

              У меня вообще вон валяется CMS уровня Юкоза (реально, без шуток). Да, времена прошли, да, никому не надо, однако если бы я не создавал ее несколько лет, по сути только благодаря ей (и php.net) оттачивая свои навыки — я бы вообще программировать не научился.


  1. Virviil
    05.01.2017 20:36

    Статья отличная, но есть вопросы


    • parsers не нужны, потому что есть Plug.Conn.fetch_query_params/1
    • а вот где вы взяли send_json_resp — я так и не понял


    1. Folklore
      05.01.2017 20:51

      Спасибо! Да, вот эту практику я и не нашёл https://hexdocs.pm/plug/Plug.Conn.html#fetch_query_params/2.
      Что касается send_json_resp/3, то я его сам написал, находится в контроллерах, выглядит так.


      defp send_json_resp(conn, response, status \\ 200) do
        put_resp_content_type(conn, "application/json")
        |> send_resp(status, response)
      end


      1. Virviil
        05.01.2017 21:13

        Для этого можно сделать отдельный plug — если у вас все ответы в json


          #router.ex
        
          plug Plug.Logger
          plug :match
          plug :dispatch  
          plug :resp_with_json
        
          def resp_with_json(conn, _opts) do
            conn
            |> Plug.Conn.put_resp_content_type("application/json")   
          end