commit a661165cc7bc169b44fc78c8f76d04e720826524 from: mtmn date: Fri May 8 09:52:26 2026 UTC feat: init commit - /dev/null commit + a661165cc7bc169b44fc78c8f76d04e720826524 blob - /dev/null blob + b1c6bc851b2dd0d76e7563e3b0b9c0dd3cc46d56 (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1,4 @@ +/.expert/ +/_build/ +/deps/ +khal_notifier blob - /dev/null blob + 605ba8a854ad8f4f7f0dc6e67e47b26aedc8804f (mode 644) --- /dev/null +++ BUILD @@ -0,0 +1,13 @@ +load("//bazel:local-deploy.bzl", "local_deploy") + +# you have to `mix escript.build` first and then copy the binary to /opt/tools +filegroup( + name = "binary", + srcs = ["shirt_linkener"], + visibility = ["//visibility:public"], +) + +local_deploy( + name = "deploy", + srcs = [":binary"], +) blob - /dev/null blob + d04fe85e03d8be1364d6f1698728c0cd9efde52f (mode 644) --- /dev/null +++ Containerfile @@ -0,0 +1,20 @@ +FROM docker.io/elixir:1.19-slim + +ENV MIX_ENV=prod +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/ + +WORKDIR /app +COPY mix.exs mix.lock ./ + +RUN mix local.hex --force && mix local.rebar --force && MIX_ENV=prod mix deps.get --only prod + +COPY config ./config +COPY lib ./lib + +RUN MIX_ENV=prod mix compile + +CMD ["mix", "run", "--no-halt"] blob - /dev/null blob + 32dc8a03c9eeee1534b5d6023c33820a6b162c50 (mode 644) --- /dev/null +++ Makefile @@ -0,0 +1,32 @@ +.PHONY: all build format lint check clean run + +AUTH_TOKEN := demo +BASE_URL := http://localhost +PORT := 4000 +DB_PATH := shortener.db + +export AUTH_TOKEN +export BASE_URL +export PORT +export DB_PATH + +all: build + +build: + mix deps.get + mix compile + +run: + mix run --no-halt + +format: + mix format + +lint: + mix credo --strict + +check: + mix dialyzer + +clean: + mix clean blob - /dev/null blob + 0ee46b333710ea711abb7114dd082a1f0ec79ace (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,46 @@ +# shirts + +A URL shortener. Links are stored in a SQLite database and served under a configurable base URL. + +## Building + +```sh +mix deps.get +mix release +``` + +## Usage + +### Create a short link + +```sh +curl -X POST http://localhost:8721/curtail \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/very/long/path"}' +``` + +Optionally provide a custom code: + +```sh +curl -X POST http://localhost:8721/curtail \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/very/long/path", "code": "my-code"}' +``` + +### Follow a short link + +``` +GET /s/ +``` + +## Configuration + +| Variable | Description | +|------------|--------------------------------------| +| `PORT` | Port to listen on (default: `8721`) | +| `HOST` | Host to bind to (default: `127.0.0.1`) | +| `BASE_URL` | Base URL used when generating links | +| `AUTH_TOKEN` | Bearer token for the create endpoint | +| `DB_PATH` | Path to the SQLite database file | blob - /dev/null blob + 7c57dd4f685188a09459a894ce21e35394d8d2dd (mode 644) --- /dev/null +++ config/config.exs @@ -0,0 +1,7 @@ +import Config + +config :shortener, + port: System.get_env("PORT", "4000") |> String.to_integer(), + auth_token: System.get_env("AUTH_TOKEN", "changeme"), + db_path: System.get_env("DB_PATH", "data.db"), + base_url: System.get_env("BASE_URL", "http://localhost") blob - /dev/null blob + 519ad59acb7c5c68bc62286661866a8e1bf013fd (mode 644) --- /dev/null +++ config/runtime.exs @@ -0,0 +1,10 @@ +import Config + +if config_env() == :prod do + config :shortener, + port: System.get_env("PORT") |> then(&(if &1, do: String.to_integer(&1), else: 4000)), + host: System.get_env("HOST") || "127.0.0.1", + auth_token: System.fetch_env!("AUTH_TOKEN"), + db_path: System.get_env("DB_PATH") || "data.db", + base_url: System.get_env("BASE_URL") || "http://localhost" +end blob - /dev/null blob + 4af396196520e8ce6be0adef854f26c2ba982be8 (mode 644) --- /dev/null +++ flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1778124196, + "narHash": "sha256-pYEytCNic/czazbV9r3tbQ6BZzqRBg/41x2dIC5ymOo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "68a8af93ff4297686cb68880845e61e5e2e41d92", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} blob - /dev/null blob + ef0b5ad3bfc222a9fff8740d4935f52bb89fee27 (mode 644) --- /dev/null +++ flake.nix @@ -0,0 +1,28 @@ +{ + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + outputs = {nixpkgs, ...}: { + packages = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"] (system: let + pkgs = nixpkgs.legacyPackages.${system}; + in { + default = pkgs.beamPackages.mixRelease { + pname = "shortener"; + version = "0.0.1"; + src = ./.; + + nativeBuildInputs = [pkgs.beamPackages.elixir_1_18]; + + MIX_HOME = "nix-mix"; + HEX_HOME = "nix-hex"; + ELIXIR_MAKE_CACHE_DIR = "nix-elixir-make"; + + mixFodDeps = pkgs.beamPackages.fetchMixDeps { + pname = "shortener-deps"; + version = "0.0.1"; + src = ./.; + sha256 = "sha256-U/tNP3s7b4bBUrQx5l4VtDHpV9DsWj6zBYj8jsM4Acw="; + }; + }; + }); + }; +} blob - /dev/null blob + 87d59f64389a6b86b3177a046b36a8ef2709ec49 (mode 644) --- /dev/null +++ lib/shortener/application.ex @@ -0,0 +1,37 @@ +defmodule Shortener.Application do + @moduledoc """ + Shortener application entry point. + Starts the database and the HTTP server (Bandit). + """ + use Application + require Logger + + @impl true + def start(_type, _args) do + port = Application.fetch_env!(:shortener, :port) + host = Application.fetch_env!(:shortener, :host) + db_path = Application.fetch_env!(:shortener, :db_path) + + children = [ + {Shortener.DB, db_path}, + {Bandit, plug: Shortener.Router, port: port, ip: parse_ip(host)} + ] + + Logger.info("Starting shortener on #{host}:#{port}") + + Supervisor.start_link(children, strategy: :one_for_one, name: Shortener.Supervisor) + end + + @impl true + def stop(_state) do + Logger.info("Shortener application stopped") + :ok + end + + defp parse_ip(ip_str) do + case :inet.parse_address(to_charlist(ip_str)) do + {:ok, ip} -> ip + _ -> {127, 0, 0, 1} + end + end +end blob - /dev/null blob + e0bfd0736b17115e0e00fe1620397d3ee950d088 (mode 644) --- /dev/null +++ lib/shortener/db.ex @@ -0,0 +1,83 @@ +defmodule Shortener.DB do + @moduledoc """ + SQLite database interface using Exqlite. + Stores and retrieves short links. + """ + + use GenServer + require Logger + + def start_link(db_path) do + GenServer.start_link(__MODULE__, db_path, name: __MODULE__) + end + + @impl true + def init(db_path) do + Process.flag(:trap_exit, true) + {:ok, conn} = Exqlite.Sqlite3.open(db_path) + + :ok = + Exqlite.Sqlite3.execute(conn, """ + CREATE TABLE IF NOT EXISTS shirts ( + code TEXT PRIMARY KEY, + url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + + Logger.info("Database ready at #{db_path}") + {:ok, conn} + end + + @doc "Insert a new short code → url mapping. Returns :ok or {:error, reason}." + def insert(code, url) do + GenServer.call(__MODULE__, {:insert, code, url}) + end + + @doc "Look up a URL by short code. Returns {:ok, url} or :not_found." + def lookup(code) do + GenServer.call(__MODULE__, {:lookup, code}) + end + + # --- server callbacks --- + + @impl true + def handle_call({:insert, code, url}, _from, conn) do + result = + with {:ok, stmt} <- + Exqlite.Sqlite3.prepare(conn, "INSERT INTO shirts (code, url) VALUES (?1, ?2)"), + :ok <- Exqlite.Sqlite3.bind(stmt, [code, url]), + :done <- Exqlite.Sqlite3.step(conn, stmt) do + _ = Exqlite.Sqlite3.release(conn, stmt) + :ok + else + {:error, reason} -> {:error, reason} + end + + {:reply, result, conn} + end + + def handle_call({:lookup, code}, _from, conn) do + result = + with {:ok, stmt} <- + Exqlite.Sqlite3.prepare(conn, "SELECT url FROM shirts WHERE code = ?1"), + :ok <- Exqlite.Sqlite3.bind(stmt, [code]), + step_result <- Exqlite.Sqlite3.step(conn, stmt) do + _ = Exqlite.Sqlite3.release(conn, stmt) + + case step_result do + {:row, [url]} -> {:ok, url} + :done -> :not_found + {:error, reason} -> {:error, reason} + end + end + + {:reply, result, conn} + end + + @impl true + def terminate(_reason, conn) do + Logger.info("Closing database connection") + Exqlite.Sqlite3.close(conn) + end +end blob - /dev/null blob + a9074146fb1c87fffa061ea78027929c88487e4c (mode 644) --- /dev/null +++ lib/shortener/router.ex @@ -0,0 +1,111 @@ +defmodule Shortener.Router do + @moduledoc """ + Plug router: + POST /curtail — create a short link (requires Bearer token) + GET /s/:code — redirect to the original URL + """ + + use Plug.Router + + plug Plug.Logger + plug :match + plug Plug.Parsers, parsers: [:json], json_decoder: Jason + plug :dispatch + + # --------------------------------------------------------------------------- + # POST /l/curtail + # Body (JSON): {"url": "https://example.com/very/long/path"} + # Optional: {"url": "...", "code": "my-custom-code"} + # Header: Authorization: Bearer + # --------------------------------------------------------------------------- + post "/curtail" do + with :ok <- check_auth(conn), + {:ok, url} <- fetch_url(conn), + code <- Map.get(conn.body_params, "code") || random_code(), + :ok <- Shortener.DB.insert(code, url) do + base_url = Application.fetch_env!(:shortener, :base_url) + short_url = "#{base_url}/s/#{code}" + + conn + |> put_resp_content_type("application/json") + |> send_resp(201, Jason.encode!(%{short_url: short_url, code: code, url: url})) + else + {:error, :unauthorized} -> + json(conn, 401, %{error: "unauthorized"}) + + {:error, :missing_url} -> + json(conn, 422, %{error: "missing required field: url"}) + + {:error, :invalid_url} -> + json(conn, 422, %{error: "url must be a valid http or https URL"}) + + {:error, _reason} -> + json(conn, 409, %{error: "could not save link"}) + end + end + + # --------------------------------------------------------------------------- + # GET /s/:code — redirect + # --------------------------------------------------------------------------- + get "/s/:code" do + case Shortener.DB.lookup(code) do + {:ok, url} -> + conn + |> put_resp_header("location", url) + |> send_resp(301, "") + + :not_found -> + json(conn, 404, %{error: "not found"}) + + {:error, _} -> + json(conn, 500, %{error: "internal error"}) + end + end + + # --------------------------------------------------------------------------- + # Catch-all + # --------------------------------------------------------------------------- + match _ do + json(conn, 404, %{error: "not found"}) + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp check_auth(conn) do + expected = "Bearer #{Application.fetch_env!(:shortener, :auth_token)}" + + case get_req_header(conn, "authorization") do + [^expected] -> :ok + _ -> {:error, :unauthorized} + end + end + + defp fetch_url(conn) do + case conn.body_params do + %{"url" => url} when is_binary(url) and url != "" -> + uri = URI.parse(url) + + if uri.scheme in ["http", "https"] and is_binary(uri.host) and uri.host != "" do + {:ok, url} + else + {:error, :invalid_url} + end + + _ -> + {:error, :missing_url} + end + end + + defp random_code do + :crypto.strong_rand_bytes(6) + |> Base.url_encode64(padding: false) + end + + defp json(conn, status, body) do + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end +end blob - /dev/null blob + ee4ba2d4b7e632de2bd5302f8988089923f3ec47 (mode 644) --- /dev/null +++ mix.exs @@ -0,0 +1,31 @@ +defmodule Shortener.MixProject do + use Mix.Project + + def project do + [ + app: :shortener, + version: "0.0.1", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {Shortener.Application, []} + ] + end + + defp deps do + [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:bandit, "~> 1.10"}, + {:plug, "~> 1.19"}, + {:exqlite, "~> 0.35"}, + {:jason, "~> 1.4"} + ] + end +end blob - /dev/null blob + 87b1dab30aff03a7b254e304adfac3daaec6b0bc (mode 644) --- /dev/null +++ mix.lock @@ -0,0 +1,27 @@ +%{ + "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "burrito": {:hex, :burrito, "1.5.0", "d68ec01df2871f1d5bc603b883a78546c75761ac73c1bec1b7ae2cc74790fcd1", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "3861abda7bffa733862b48da3e03df0b4cd41abf6fd24b91745f5c16d971e5fa"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, +}