Расширение API от Vk для стикеров на Elixir

    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-чатам Wunsh && Elixir и ProElixir.


    2. У отечественного Elixir сообщества начинает появляться единая площадка в лице проекта Wunsh.ru. Сейчас ребята во всю пишут новую версию сайта. Но уже у них есть подписка на рассылку. В ней нет ничего нелегального, раз в недельку будет приходить письмо с подборкой статей про Elixir на русском языке.

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

    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

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

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


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

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

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

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

              • +1

                Навскидку:


                • Более привычный "хипстерам" синтаксис == много новых людей == развитие экосистемы
                • Честные utf-8 строки везде
                • Консистентная стандартная библиотека
                • Нормальный менеджер зависимостей
                • Mix
                • Достаточно удобное метапрограммирование
              • 0
                Огого! Ну да, пожалуй из-за Эрланга стоит присмотреться к нему повнимательнее, благодарю за введение) Ну а для веб-разработки (именно сайтов) он как, годится, или как для Питона без Джанго какого-нибудь лучше не начинать?
                • 0
                  Для web-разработки попробуйте, например, посмотреть на Zotonic. Это Erlang :)
                  • 0

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

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

                    Для Elixir есть Phoenix.

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

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

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

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

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

                        P. S. А «Hello World!» знатный получился)
                        • 0
                          Ну вот видите, если не предоставляет, значит создатели тоже не понимают, зачем он нужен.

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

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

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

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

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


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

                      Спасибо! Да, вот эту практику я и не нашёл 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
                      • 0

                        Для этого можно сделать отдельный 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

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.