├── test ├── test_helper.exs ├── shorten_api │ ├── hesh_id_test.exs │ ├── router_test.exs │ ├── db │ │ └── link_test.exs │ └── plug │ │ └── rest_test.exs └── shorten_api_test.exs ├── .formatter.exs ├── lib ├── shorten_api │ ├── repo.ex │ ├── application.ex │ ├── hash_id.ex │ ├── router.ex │ ├── db │ │ └── link.ex │ └── plug │ │ └── rest.ex └── shorten_api.ex ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── config └── config.exs ├── priv └── repo │ └── migrations │ └── 20220421013047_create_links.exs ├── .gitignore ├── mix.exs ├── LICENSE ├── README.md └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/shorten_api/hesh_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.HashIdTest do 2 | use ExUnit.Case, async: true 3 | doctest ShortenApi.HashId 4 | end 5 | -------------------------------------------------------------------------------- /lib/shorten_api/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.Repo do 2 | @moduledoc false 3 | use Ecto.Repo, 4 | otp_app: :shorten_api, 5 | adapter: Ecto.Adapters.Postgres 6 | end 7 | -------------------------------------------------------------------------------- /test/shorten_api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortenApiTest do 2 | use ExUnit.Case 3 | doctest ShortenApi 4 | 5 | test "greets the world" do 6 | assert ShortenApi.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic dependabot.yml file with 2 | # minimum configuration for two package managers 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for Elixir 7 | - package-ecosystem: "mix" 8 | directory: "/" 9 | schedule: 10 | interval: "monthly" 11 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :shorten_api, ShortenApi.Repo, 4 | database: "shorten_db", 5 | username: "postgres", 6 | password: "postgres", 7 | hostname: "localhost" 8 | 9 | config :shorten_api, ecto_repos: [ShortenApi.Repo] 10 | 11 | config :shorten_api, cowboy_port: 8080 12 | -------------------------------------------------------------------------------- /lib/shorten_api.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi do 2 | @moduledoc """ 3 | Documentation for `ShortenApi`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> ShortenApi.hello() 12 | :world 13 | 14 | """ 15 | def hello do 16 | :world 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220421013047_create_links.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.Repo.Migrations.CreateLinks do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:links, primary_key: false) do 6 | add :hash, :string, primary_key: true, null: false 7 | add :url, :string, null: false 8 | 9 | timestamps() 10 | end 11 | 12 | create unique_index(:links, [:url]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | shorten_api-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /lib/shorten_api/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | require Logger 8 | 9 | @impl true 10 | def start(_type, _args) do 11 | children = [ 12 | # Starts a worker by calling: ShortenApi.Worker.start_link(arg) 13 | # {ShortenApi.Worker, arg} 14 | {Plug.Cowboy, scheme: :http, plug: ShortenApi.Router, options: [port: cowboy_port()]}, 15 | ShortenApi.Repo 16 | ] 17 | 18 | # See https://hexdocs.pm/elixir/Supervisor.html 19 | # for other strategies and supported options 20 | opts = [strategy: :one_for_one, name: ShortenApi.Supervisor] 21 | 22 | Logger.info("Starting application...") 23 | 24 | Supervisor.start_link(children, opts) 25 | end 26 | 27 | defp cowboy_port, do: Application.fetch_env!(:shorten_api, :cowboy_port) 28 | end 29 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :shorten_api, 7 | version: "0.1.0", 8 | elixir: "~> 1.13", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | test_coverage: [tool: ExCoveralls] 12 | ] 13 | end 14 | 15 | # Run "mix help compile.app" to learn about applications. 16 | def application do 17 | [ 18 | extra_applications: [:logger], 19 | mod: {ShortenApi.Application, []} 20 | ] 21 | end 22 | 23 | # Run "mix help deps" to learn about dependencies. 24 | defp deps do 25 | [ 26 | # {:dep_from_hexpm, "~> 0.3.0"}, 27 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 28 | {:plug_cowboy, "~> 2.0"}, 29 | {:ecto_sql, "~> 3.2"}, 30 | {:postgrex, "~> 0.15"}, 31 | {:jason, "~> 1.3"}, 32 | {:excoveralls, "~> 0.14.4", only: [:test]} 33 | ] 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mogeko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/shorten_api/hash_id.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.HashId do 2 | @moduledoc """ 3 | Handling HashId 4 | """ 5 | @hash_id_length 8 6 | 7 | @doc """ 8 | Generates a HashId 9 | 10 | ## Examples 11 | 12 | iex> ShortenApi.HashId.generate("ABC") 13 | {:ok, "PAG9uybz"} 14 | 15 | iex> ShortenApi.HashId.generate(:atom) 16 | :error 17 | 18 | iex> ShortenApi.HashId.generate(nil) 19 | :error 20 | 21 | """ 22 | @spec generate(String.t() | any()) :: :error | {:ok, String.t()} 23 | def generate(text) when is_binary(text), do: {:ok, generate!(text)} 24 | def generate(any), do: generate!(any) 25 | 26 | @doc """ 27 | ## Examples 28 | 29 | iex> ShortenApi.HashId.generate!("ABC") 30 | "PAG9uybz" 31 | 32 | iex> ShortenApi.HashId.generate!(:atom) 33 | :error 34 | 35 | iex> ShortenApi.HashId.generate!(nil) 36 | :error 37 | 38 | """ 39 | @spec generate!(String.t() | any()) :: :error | String.t() 40 | def generate!(text) when is_binary(text) do 41 | text 42 | |> (&:crypto.hash(:sha, &1)).() 43 | |> Base.encode64() 44 | |> binary_part(0, @hash_id_length) 45 | end 46 | 47 | def generate!(_any), do: :error 48 | end 49 | -------------------------------------------------------------------------------- /lib/shorten_api/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.Router do 2 | @moduledoc false 3 | use Plug.Router 4 | import Plug.Conn 5 | 6 | plug(Plug.Logger) 7 | plug(Plug.Parsers, parsers: [:urlencoded, :json], json_decoder: Jason) 8 | plug(:match) 9 | plug(:dispatch) 10 | 11 | get "/" do 12 | [host_url | _tail] = get_req_header(conn, "host") 13 | resp_msg = Jason.encode!(%{message: "#{conn.scheme}://#{host_url}/api/v1"}) 14 | 15 | conn 16 | |> put_resp_content_type("application/json") 17 | |> send_resp(200, resp_msg) 18 | end 19 | 20 | forward("/api/v1", to: ShortenApi.Plug.REST) 21 | 22 | get "/:hash" do 23 | import Ecto.Query, only: [from: 2] 24 | query = from l in ShortenApi.DB.Link, where: l.hash == ^hash, select: l.url 25 | url = ShortenApi.Repo.one(query) 26 | 27 | if is_nil(url) do 28 | conn 29 | |> put_resp_content_type("application/json") 30 | |> send_resp(404, Jason.encode!(%{message: "Not Found"})) 31 | else 32 | conn 33 | |> put_resp_header("location", url) 34 | |> send_resp(302, "Redirect to #{url}") 35 | end 36 | end 37 | 38 | match _ do 39 | resp_msg = Jason.encode!(%{message: "Not Found"}) 40 | 41 | conn 42 | |> put_resp_content_type("application/json") 43 | |> send_resp(404, resp_msg) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/shorten_api/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.RouterTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias ShortenApi.Router 6 | 7 | setup_all do 8 | opts = Router.init([]) 9 | {:ok, opts: opts} 10 | end 11 | 12 | test "returns welcome message", %{opts: opts} do 13 | conn = 14 | :get 15 | |> conn("/") 16 | |> put_req_header("host", "www.example.com") 17 | |> Router.call(opts) 18 | 19 | assert conn.state == :sent 20 | assert conn.status == 200 21 | end 22 | 23 | test "returns 404", %{opts: opts} do 24 | conn = 25 | :get 26 | |> conn("/the/path/is/missing") 27 | |> Router.call(opts) 28 | 29 | assert conn.state == :sent 30 | assert conn.status == 404 31 | end 32 | 33 | test "URL not found in database", %{opts: opts} do 34 | conn = 35 | :get 36 | |> conn("/not_found") 37 | |> Router.call(opts) 38 | 39 | assert conn.state == :sent 40 | assert conn.status == 404 41 | end 42 | 43 | test "return 403 from /api/v1", %{opts: opts} do 44 | conn = 45 | :get 46 | |> conn("/api/v1?url=www.example.com") 47 | |> put_req_header("host", "www.example.com") 48 | |> Router.call(opts) 49 | 50 | assert conn.state == :sent 51 | assert conn.status == 403 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/shorten_api/db/link_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.DB.LinkTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias ShortenApi.DB.Link 5 | import Ecto.Changeset, only: [change: 2] 6 | 7 | @example_url "https://www.example.com" 8 | @example_hash "dA5zl5B8" 9 | 10 | setup_all do 11 | schema = %Link{} 12 | changeset = change(schema, %{hash: @example_hash, url: @example_url}) 13 | {:ok, schema: schema, changeset: changeset} 14 | end 15 | 16 | test "pass all checks", %{schema: schema} do 17 | changeset = Link.changeset(schema, %{hash: @example_hash, url: @example_url}) 18 | 19 | assert changeset.valid? 20 | end 21 | 22 | test "more complex url", %{schema: schema} do 23 | url = "http://www.example.com?user=mogeko&email=mogeko@example.com" 24 | changeset = Link.changeset(schema, %{hash: "lnI0HDUo", url: url}) 25 | 26 | assert changeset.valid? 27 | end 28 | 29 | test "if hash is empty", %{schema: schema} do 30 | changeset = Link.changeset(schema, %{url: @example_url}) 31 | 32 | assert !changeset.valid? 33 | assert changeset.errors[:hash] == {"does not match target link", []} 34 | end 35 | 36 | test "hash should be matched with hash of url", %{changeset: changeset} do 37 | changeset = Link.validate_hash_matched(changeset, :hash, :url) 38 | 39 | assert changeset.valid? 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/shorten_api/plug/rest_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.Plug.RESTTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias ShortenApi.Plug.REST 6 | 7 | @example_url "http://www.example.com" 8 | @example_hash "dA5zl5B8" 9 | 10 | setup_all do 11 | get_conn = 12 | :get 13 | |> conn("/") 14 | |> put_req_header("host", "www.example.com") 15 | 16 | alias ShortenApi.DB.Link 17 | struct = %Link{hash: @example_hash} 18 | changeset = Link.changeset(%Link{}, %{url: @example_url}) 19 | 20 | {:ok, conn: get_conn, struct: struct, changeset: changeset} 21 | end 22 | 23 | test "put short link msg to resp", %{conn: get_conn, struct: struct} do 24 | resp_msg = %REST.Resp{short_link: "#{@example_url}/#{@example_hash}"} 25 | conn = REST.put_resp_msg(get_conn, {:ok, struct}) 26 | 27 | assert conn.state == :set 28 | assert conn.status == 201 29 | assert conn.resp_body == Jason.encode!(resp_msg) 30 | end 31 | 32 | test "should put error msg_404 to resp", %{conn: get_conn} do 33 | err_msg = %REST.ErrResp{message: "Parameter error"} 34 | conn = REST.put_resp_msg(get_conn, :error) 35 | 36 | assert conn.state == :set 37 | assert conn.status == 404 38 | assert conn.resp_body == Jason.encode!(err_msg) 39 | end 40 | 41 | test "should put error msg_403 to resp", %{conn: get_conn, changeset: changeset} do 42 | err_msg = %REST.ErrResp{message: "Wrong format"} 43 | conn = REST.put_resp_msg(get_conn, {:error, changeset}) 44 | 45 | assert conn.state == :set 46 | assert conn.status == 403 47 | assert conn.resp_body == Jason.encode!(err_msg) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | push: 4 | branches-ignore: 5 | - master 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | services: 12 | postgres: 13 | image: postgres 14 | env: 15 | POSTGRES_PASSWORD: postgres 16 | POSTGRES_USER: postgres 17 | POSTGRES_DB: shorten_db 18 | options: >- 19 | --health-cmd pg_isready 20 | --health-interval 10s 21 | --health-timeout 5s 22 | --health-retries 5 23 | ports: 24 | - 5432:5432 25 | 26 | name: Test Elixir 27 | steps: 28 | - uses: actions/checkout@v2 29 | with: 30 | fetch-depth: 0 31 | 32 | - name: Set up cache 33 | id: cache-deps-and-build 34 | uses: actions/cache@v3 35 | with: 36 | path: | 37 | _build 38 | deps 39 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 40 | restore-keys: ${{ runner.os }}-mix- 41 | 42 | - name: Set up Elixir 43 | uses: erlef/setup-beam@v1 44 | with: 45 | otp-version: 24 46 | elixir-version: 1.13.4 47 | 48 | - name: Set up deps 49 | run: mix deps.get 50 | 51 | - name: Compile Elixir 52 | run: mix compile 53 | 54 | - name: Set up Database 55 | run: | 56 | mix ecto.create 57 | mix ecto.migrate 58 | 59 | - name: Run Test 60 | run: mix test 61 | 62 | - name: Export Test Coverage 63 | run: MIX_ENV=test mix coveralls.json 64 | 65 | - name: Push Test Coverage Data 66 | uses: codecov/codecov-action@v2 67 | with: 68 | files: ./cover/excoveralls.json 69 | -------------------------------------------------------------------------------- /lib/shorten_api/db/link.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.DB.Link do 2 | @moduledoc false 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | 6 | @primary_key {:hash, :string, [autogenerate: false]} 7 | schema "links" do 8 | field(:url, :string) 9 | timestamps() 10 | end 11 | 12 | @doc false 13 | @spec changeset(Ecto.Schema.t() | map, map) :: Ecto.Changeset.t() 14 | def changeset(struct, params) do 15 | struct 16 | |> cast(params, [:hash, :url]) 17 | |> validate_required([:hash, :url]) 18 | |> validate_format(:url, ~r/htt(p|ps):\/\/(\w+.)+/) 19 | |> validate_hash_matched(:hash, :url) 20 | |> unique_constraint(:url) 21 | end 22 | 23 | @doc """ 24 | Check if the `:hash` matches the hash of the `:text`. 25 | 26 | ## Options 27 | 28 | * `:hash` - the hash value, it will be used to match the hash of the `:text` 29 | * `:text` - its hash should be equal with `:hash` 30 | 31 | ## Examples 32 | 33 | validate_hash_matched(changeset, :hash, :url) 34 | 35 | """ 36 | @spec validate_hash_matched(Ecto.Changeset.t(), atom, atom) :: Ecto.Changeset.t() 37 | def validate_hash_matched(changeset, hash, text) do 38 | import ShortenApi.HashId, only: [generate!: 1] 39 | target_hash = get_field(changeset, hash) 40 | target_text = get_field(changeset, text) 41 | 42 | if generate!(target_text) != target_hash do 43 | add_error(changeset, :hash, "does not match target link") 44 | else 45 | changeset 46 | end 47 | end 48 | 49 | @doc false 50 | @spec write(String.t(), String.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} 51 | def write(hash, url) do 52 | %ShortenApi.DB.Link{} 53 | |> changeset(%{hash: hash, url: url}) 54 | |> ShortenApi.Repo.insert() 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShortenApi 2 | 3 | [![.github/workflows/test.yml](https://github.com/mogeko/link-shortener-api/actions/workflows/test.yml/badge.svg)](https://github.com/mogeko/link-shortener-api/actions/workflows/test.yml) 4 | [![codecov](https://codecov.io/gh/mogeko/link-shortener-api/branch/master/graph/badge.svg?token=4lvEOie9Hj)](https://codecov.io/gh/mogeko/link-shortener-api) 5 | 6 | **This is a project for learning Elixir.** 7 | 8 | I wrote a blog detailing my development process: [Elixir 练手 + 实战:短链接服务](https://mogeko.me/posts/zh-cn/092/). 9 | 10 | The main purpose of this project is to construct a short link service with Elixir + Plug + Ecto(PostgreSQL). 11 | 12 | ## Installation 13 | 14 | First, you need to make sure you have: 15 | 16 | - Elixir 17 | - PostgreSQL 18 | 19 | Then configure and start your PostgreSQL database. 20 | 21 | Next step, clone this GitHub repository, install dependencies, and compile: 22 | 23 | ```bash 24 | git clone https://github.com/mogeko/link-shortener-api.git 25 | cd link-shortener-api 26 | mix deps.get && mix compile 27 | ``` 28 | 29 | ## Usage 30 | 31 | Start the service first: 32 | 33 | ```bash 34 | mix run --no-halt 35 | ``` 36 | 37 | Then open another terminal and use the GET or POST method to send a message to our server: 38 | 39 | ```bash 40 | curl --request POST \ 41 | --url http://localhost:8080/api/v1 \ 42 | --header 'content-type: application/json' \ 43 | --data '{ 44 | "url": "https://github.com/mogeko/link-shortener-api" 45 | }' 46 | ``` 47 | 48 | it will return your short link: 49 | 50 | ```json 51 | { 52 | "ok":true, 53 | "short_link":"http://localhost:8080/rX3wvhBV" 54 | } 55 | ``` 56 | 57 | Open your browser and visit `http://localhost:8080/rX3wvhBV`. 58 | 59 | It will jump to . 60 | 61 | ## License 62 | 63 | The code in this project is released under the [MIT License](https://github.com/mogeko/link-shortener-api/blob/master/LICENSE). 64 | -------------------------------------------------------------------------------- /lib/shorten_api/plug/rest.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortenApi.Plug.REST.Resp do 2 | @moduledoc false 3 | @derive Jason.Encoder 4 | defstruct ok: true, short_link: "" 5 | @type t :: %__MODULE__{ok: boolean, short_link: String.t()} 6 | end 7 | 8 | defmodule ShortenApi.Plug.REST.ErrResp do 9 | @moduledoc false 10 | @derive Jason.Encoder 11 | defstruct ok: false, message: "" 12 | @type t :: %__MODULE__{ok: boolean, message: String.t()} 13 | end 14 | 15 | defmodule ShortenApi.Plug.REST do 16 | @moduledoc """ 17 | A REST Plug for generating short links. 18 | """ 19 | @behaviour Plug 20 | import Plug.Conn 21 | 22 | @doc """ 23 | Pass `Plug.opts` to `call/2` 24 | """ 25 | @spec init(Plug.opts()) :: Plug.opts() 26 | def init(opts), do: opts 27 | 28 | @doc """ 29 | Process `Plug.Conn.t`, return the generated short link. 30 | """ 31 | @spec call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() 32 | def call(conn, _opts) do 33 | resp_msg = 34 | with {:ok, url} <- Map.fetch(conn.params, "url"), 35 | {:ok, hash} <- ShortenApi.HashId.generate(url) do 36 | ShortenApi.DB.Link.write(hash, url) 37 | end 38 | 39 | conn 40 | |> put_resp_content_type("application/json") 41 | |> put_resp_msg(resp_msg) 42 | |> send_resp() 43 | end 44 | 45 | @doc false 46 | @spec put_resp_msg( 47 | Plug.Conn.t(), 48 | :error | {:error, Ecto.Changeset.t()} | {:ok, Ecto.Schema.t()} 49 | ) :: Plug.Conn.t() 50 | def put_resp_msg(conn, {:ok, struct}) do 51 | alias ShortenApi.Plug.REST.Resp 52 | [host_url | _tail] = get_req_header(conn, "host") 53 | short_link = "#{conn.scheme}://#{host_url}/#{struct.hash}" 54 | resp_json = Jason.encode!(%Resp{short_link: short_link}) 55 | resp(conn, 201, resp_json) 56 | end 57 | 58 | def put_resp_msg(conn, {:error, _changeset}) do 59 | alias ShortenApi.Plug.REST.ErrResp 60 | resp_json = Jason.encode!(%ErrResp{message: "Wrong format"}) 61 | resp(conn, 403, resp_json) 62 | end 63 | 64 | def put_resp_msg(conn, :error) do 65 | alias ShortenApi.Plug.REST.ErrResp 66 | resp_json = Jason.encode!(%ErrResp{message: "Parameter error"}) 67 | resp(conn, 404, resp_json) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 3 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 4 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 5 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 6 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 7 | "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, 8 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 9 | "ecto": {:hex, :ecto, "3.9.0", "7c74fc0d950a700eb7019057ff32d047ed7f19b57c1b2ca260cf0e565829101d", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fed5ebc5831378b916afd0b5852a0c5bb3e7390665cc2b0ec8ab0c712495b73d"}, 10 | "ecto_sql": {:hex, :ecto_sql, "3.9.0", "2bb21210a2a13317e098a420a8c1cc58b0c3421ab8e3acfa96417dab7817918c", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8f3f720073b8b1ac4c978be25fa7960ed7fd44997420c304a4a2e200b596453"}, 11 | "excoveralls": {:hex, :excoveralls, "0.14.6", "610e921e25b180a8538229ef547957f7e04bd3d3e9a55c7c5b7d24354abbba70", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "0eceddaa9785cfcefbf3cd37812705f9d8ad34a758e513bb975b081dce4eb11e"}, 12 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 13 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 14 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 15 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 16 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 17 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 18 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 19 | "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, 20 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, 21 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 22 | "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, 23 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 24 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 25 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 26 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 27 | } 28 | --------------------------------------------------------------------------------