commit - /dev/null
commit + a661165cc7bc169b44fc78c8f76d04e720826524
blob - /dev/null
blob + b1c6bc851b2dd0d76e7563e3b0b9c0dd3cc46d56 (mode 644)
--- /dev/null
+++ .gitignore
+/.expert/
+/_build/
+/deps/
+khal_notifier
blob - /dev/null
blob + 605ba8a854ad8f4f7f0dc6e67e47b26aedc8804f (mode 644)
--- /dev/null
+++ BUILD
+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
+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
+.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
+# 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 <token>" \
+ -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 <token>" \
+ -H "Content-Type: application/json" \
+ -d '{"url": "https://example.com/very/long/path", "code": "my-code"}'
+```
+
+### Follow a short link
+
+```
+GET /s/<code>
+```
+
+## 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
+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
+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
+{
+ "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
+{
+ 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
+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
+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
+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 <token>
+ # ---------------------------------------------------------------------------
+ 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
+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
+%{
+ "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"},
+}