feat: expose CRUD, onboarding, pubsub via web
This commit is contained in:
@@ -5,3 +5,14 @@ config :localiserd, Localiser.Repo,
|
||||
pool_size: 5
|
||||
|
||||
config :localiserd, ecto_repos: [Localiser.Repo]
|
||||
|
||||
config :localiserd, Localiser.Web.Endpoint,
|
||||
adapter: Bandit.PhoenixAdapter,
|
||||
http: [port: 4000],
|
||||
secret_key_base: System.get_env("SECRET_KEY_BASE") ||
|
||||
"localiser_dev_secret_key_base_change_in_prod_min64chars!!",
|
||||
server: true,
|
||||
render_errors: [formats: [json: Localiser.ErrorView], layout: false]
|
||||
|
||||
config :localiserd, :jwt_secret,
|
||||
System.get_env("JWT_SECRET") || "localiser_dev_jwt_secret_change_in_prod!!"
|
||||
|
||||
@@ -7,6 +7,7 @@ defmodule Localiser.Application do
|
||||
Localiser.Repo,
|
||||
{Registry, keys: :unique, name: Localiser.Registry},
|
||||
{Phoenix.PubSub, name: Localiser.PubSub},
|
||||
Localiser.Web.Endpoint,
|
||||
Localiser.MQTT.Supervisor,
|
||||
Localiser.RSSI.Buffer,
|
||||
Localiser.Localisation.Floor.Manager,
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule Localiser.Domain.Floors do
|
||||
end
|
||||
|
||||
def get_floor!(id), do: Repo.get!(Floor, id)
|
||||
def any?, do: Repo.exists?(Floor)
|
||||
|
||||
def create_floor(attrs) do
|
||||
case %Floor{} |> Floor.changeset(attrs) |> Repo.insert() do
|
||||
|
||||
@@ -9,6 +9,8 @@ defmodule Localiser.Domain.Rooms do
|
||||
Repo.all(Room)
|
||||
end
|
||||
|
||||
def any?, do: Repo.exists?(Room)
|
||||
|
||||
def list_rooms_for_floor(floor_id) do
|
||||
Room
|
||||
|> where([r], r.floor_id == ^floor_id)
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule Localiser.Domain.Schema.User do
|
||||
field :username, :string
|
||||
field :password_hash, :string, redact: true
|
||||
field :password, :string, virtual: true, redact: true
|
||||
field :is_admin, :boolean, default: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
@@ -13,7 +14,7 @@ defmodule Localiser.Domain.Schema.User do
|
||||
@doc false
|
||||
def changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:username, :password])
|
||||
|> cast(attrs, [:username, :password, :is_admin])
|
||||
|> validate_required([:username, :password])
|
||||
|> validate_length(:password, min: 8)
|
||||
|> unique_constraint(:username)
|
||||
|
||||
@@ -9,6 +9,12 @@ defmodule Localiser.Domain.Sensors do
|
||||
Repo.all(Sensor)
|
||||
end
|
||||
|
||||
def list_unplaced do
|
||||
Sensor |> where([s], is_nil(s.room_id)) |> Repo.all()
|
||||
end
|
||||
|
||||
def any_placed?, do: Repo.exists?(from s in Sensor, where: not is_nil(s.room_id))
|
||||
|
||||
def list_sensors_for_room(room_id) do
|
||||
Sensor
|
||||
|> where([s], s.room_id == ^room_id)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
defmodule Localiser.Domain.System do
|
||||
alias Localiser.Domain.{Floors, Rooms, Sensors, Tags, Users}
|
||||
|
||||
def onboarding_status do
|
||||
%{
|
||||
has_admin: Users.any?(),
|
||||
has_floors: Floors.any?(),
|
||||
has_rooms: Rooms.any?(),
|
||||
has_sensors_placed: Sensors.any_placed?(),
|
||||
has_tags: Tags.any?()
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,8 @@ defmodule Localiser.Domain.Tags do
|
||||
Repo.all(Tag)
|
||||
end
|
||||
|
||||
def any?, do: Repo.exists?(Tag)
|
||||
|
||||
def get_tag!(id), do: Repo.get!(Tag, id)
|
||||
|
||||
def get_tag_by_tag_id(tag_id) do
|
||||
|
||||
@@ -2,8 +2,19 @@ defmodule Localiser.Domain.Users do
|
||||
alias Localiser.Repo
|
||||
alias Localiser.Domain.Schema.User
|
||||
|
||||
def list_users, do: Repo.all(User)
|
||||
|
||||
def get_user!(id), do: Repo.get!(User, id)
|
||||
|
||||
def get_user(id) do
|
||||
case Repo.get(User, id) do
|
||||
nil -> {:error, :not_found}
|
||||
user -> {:ok, user}
|
||||
end
|
||||
end
|
||||
|
||||
def any?, do: Repo.exists?(User)
|
||||
|
||||
def get_user_by_username(username) do
|
||||
Repo.get_by(User, username: username)
|
||||
end
|
||||
@@ -14,6 +25,20 @@ defmodule Localiser.Domain.Users do
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_user(%User{} = user, attrs) do
|
||||
user
|
||||
|> User.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def promote_to_admin(%User{} = user) do
|
||||
user
|
||||
|> Ecto.Changeset.change(is_admin: true)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_user(%User{} = user), do: Repo.delete(user)
|
||||
|
||||
def authenticate_user(username, password) do
|
||||
user = get_user_by_username(username)
|
||||
|
||||
|
||||
@@ -81,10 +81,9 @@ defmodule Localiser.Localisation.Room.Server do
|
||||
def handle_info(_msg, state), do: {:noreply, state}
|
||||
|
||||
defp broadcast(room_id, occupants) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
@pubsub,
|
||||
"room:#{room_id}",
|
||||
{:room_occupancy_changed, room_id, occupants}
|
||||
)
|
||||
msg = {:room_occupancy_changed, room_id, occupants}
|
||||
|
||||
Phoenix.PubSub.broadcast(@pubsub, "room:#{room_id}", msg)
|
||||
Phoenix.PubSub.broadcast(@pubsub, "rooms:occupancy", msg)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
defmodule Localiser.Web.ApiSpec do
|
||||
alias OpenApiSpex.{Components, Info, OpenApi, Paths, SecurityScheme, Server}
|
||||
|
||||
@behaviour OpenApi
|
||||
|
||||
@impl OpenApi
|
||||
def spec do
|
||||
%OpenApi{
|
||||
info: %Info{
|
||||
title: "localiserd API",
|
||||
version: "1.0",
|
||||
description: "BLE room-level localisation server REST API"
|
||||
},
|
||||
servers: [%Server{url: "http://localhost:4000"}],
|
||||
paths: Paths.from_router(Localiser.Web.Router),
|
||||
components: %Components{
|
||||
securitySchemes: %{
|
||||
"bearerAuth" => %SecurityScheme{
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|> OpenApiSpex.resolve_schema_modules()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
defmodule Localiser.Web.Channels.ParticlesChannel do
|
||||
use Phoenix.Channel
|
||||
|
||||
@impl true
|
||||
def join("particles:" <> tag_id, _params, socket) do
|
||||
Phoenix.PubSub.subscribe(Localiser.PubSub, "particles:#{tag_id}")
|
||||
{:ok, assign(socket, :tag_id, tag_id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:particles_updated, payload}, socket) do
|
||||
push(socket, "particles_updated", %{
|
||||
tag_id: payload.tag_id,
|
||||
estimate: payload.estimate,
|
||||
particles: payload.particles
|
||||
})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(_msg, socket), do: {:noreply, socket}
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
defmodule Localiser.Web.Channels.RoomChannel do
|
||||
use Phoenix.Channel
|
||||
|
||||
@impl true
|
||||
def join("rooms:occupancy", _params, socket) do
|
||||
Phoenix.PubSub.subscribe(Localiser.PubSub, "rooms:occupancy")
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:room_occupancy_changed, room_id, occupants}, socket) do
|
||||
push(socket, "occupancy_changed", %{
|
||||
room_id: room_id,
|
||||
occupants: MapSet.to_list(occupants)
|
||||
})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(_msg, socket), do: {:noreply, socket}
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
defmodule Localiser.Web.Channels.SensorsChannel do
|
||||
use Phoenix.Channel
|
||||
|
||||
@impl true
|
||||
def join("sensors", _params, socket) do
|
||||
Phoenix.PubSub.subscribe(Localiser.PubSub, "sensors")
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:sensor_announced, sensor}, socket) do
|
||||
push(socket, "sensor_announced", render_sensor(sensor))
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:sensor_enrolled, sensor}, socket) do
|
||||
push(socket, "sensor_enrolled", render_sensor(sensor))
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:sensor_unenrolled, sensor_id}, socket) do
|
||||
push(socket, "sensor_unenrolled", %{sensor_id: sensor_id})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:calibration_complete, sensor_id}, socket) do
|
||||
push(socket, "calibration_complete", %{sensor_id: sensor_id})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(_msg, socket), do: {:noreply, socket}
|
||||
|
||||
defp render_sensor(sensor) do
|
||||
%{
|
||||
id: sensor.id,
|
||||
sensor_id: sensor.sensor_id,
|
||||
room_id: sensor.room_id,
|
||||
floor_x: sensor.floor_x,
|
||||
floor_y: sensor.floor_y,
|
||||
rssi_ref: sensor.rssi_ref
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
defmodule Localiser.Web.Controllers.FloorController do
|
||||
use Phoenix.Controller, formats: [:json]
|
||||
use OpenApiSpex.ControllerSpecs
|
||||
|
||||
alias Localiser.Domain.Floors
|
||||
alias Localiser.Web.Schemas
|
||||
|
||||
tags ["Floors"]
|
||||
security [%{"bearerAuth" => []}]
|
||||
|
||||
operation :index,
|
||||
summary: "List all floors",
|
||||
responses: [
|
||||
ok: {"Floor list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Floor}},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :show,
|
||||
summary: "Get a floor",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
ok: {"Floor", "application/json", Schemas.Floor},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :create,
|
||||
summary: "Create a floor",
|
||||
request_body: {"Floor params", "application/json", Schemas.FloorParams, required: true},
|
||||
responses: [
|
||||
created: {"Created floor", "application/json", Schemas.Floor},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :update,
|
||||
summary: "Update a floor",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
request_body: {"Floor params", "application/json", Schemas.FloorUpdateParams},
|
||||
responses: [
|
||||
ok: {"Updated floor", "application/json", Schemas.Floor},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :delete,
|
||||
summary: "Delete a floor (also deletes all rooms and unenrols sensors)",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
no_content: "Deleted",
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
def index(conn, _params) do
|
||||
json(conn, Enum.map(Floors.list_floors(), &render_floor/1))
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
floor = Floors.get_floor!(id)
|
||||
json(conn, render_floor(floor))
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
case Floors.create_floor(params) do
|
||||
{:ok, floor} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(render_floor(floor))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id} = params) do
|
||||
floor = Floors.get_floor!(id)
|
||||
attrs = Map.drop(params, ["id"])
|
||||
|
||||
case Floors.update_floor(floor, attrs) do
|
||||
{:ok, updated} ->
|
||||
json(conn, render_floor(updated))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id}) do
|
||||
floor = Floors.get_floor!(id)
|
||||
{:ok, _} = Floors.delete_floor(floor)
|
||||
send_resp(conn, :no_content, "")
|
||||
end
|
||||
|
||||
defp render_floor(floor) do
|
||||
%{
|
||||
id: floor.id,
|
||||
name: floor.name,
|
||||
width: floor.width,
|
||||
height: floor.height
|
||||
}
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, val}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(val))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
defmodule Localiser.Web.Controllers.OnboardingController do
|
||||
use Phoenix.Controller, formats: [:json]
|
||||
use OpenApiSpex.ControllerSpecs
|
||||
|
||||
alias Localiser.Domain.System
|
||||
alias Localiser.Web.Schemas
|
||||
|
||||
tags ["Onboarding"]
|
||||
security []
|
||||
|
||||
operation :status,
|
||||
summary: "Get onboarding checklist status",
|
||||
responses: [
|
||||
ok: {"Onboarding status", "application/json", Schemas.OnboardingStatus}
|
||||
]
|
||||
|
||||
def status(conn, _params) do
|
||||
json(conn, System.onboarding_status())
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,130 @@
|
||||
defmodule Localiser.Web.Controllers.RoomController do
|
||||
use Phoenix.Controller, formats: [:json]
|
||||
use OpenApiSpex.ControllerSpecs
|
||||
|
||||
alias Localiser.Domain.Rooms
|
||||
alias Localiser.Web.Schemas
|
||||
|
||||
tags ["Rooms"]
|
||||
security [%{"bearerAuth" => []}]
|
||||
|
||||
operation :index,
|
||||
summary: "List rooms for a floor",
|
||||
parameters: [floor_id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
ok: {"Room list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Room}},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :show,
|
||||
summary: "Get a room",
|
||||
parameters: [
|
||||
floor_id: [in: :path, type: :integer, required: true],
|
||||
id: [in: :path, type: :integer, required: true]
|
||||
],
|
||||
responses: [
|
||||
ok: {"Room", "application/json", Schemas.Room},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :create,
|
||||
summary: "Create a room",
|
||||
parameters: [floor_id: [in: :path, type: :integer, required: true]],
|
||||
request_body: {"Room params", "application/json", Schemas.RoomParams, required: true},
|
||||
responses: [
|
||||
created: {"Created room", "application/json", Schemas.Room},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :update,
|
||||
summary: "Update a room",
|
||||
parameters: [
|
||||
floor_id: [in: :path, type: :integer, required: true],
|
||||
id: [in: :path, type: :integer, required: true]
|
||||
],
|
||||
request_body: {"Room params", "application/json", Schemas.RoomUpdateParams},
|
||||
responses: [
|
||||
ok: {"Updated room", "application/json", Schemas.Room},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :delete,
|
||||
summary: "Delete a room (also unenrols sensors)",
|
||||
parameters: [
|
||||
floor_id: [in: :path, type: :integer, required: true],
|
||||
id: [in: :path, type: :integer, required: true]
|
||||
],
|
||||
responses: [
|
||||
no_content: "Deleted",
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
def index(conn, %{"floor_id" => floor_id}) do
|
||||
rooms = Rooms.list_rooms_for_floor(floor_id)
|
||||
json(conn, Enum.map(rooms, &render_room/1))
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
room = Rooms.get_room!(id)
|
||||
json(conn, render_room(room))
|
||||
end
|
||||
|
||||
def create(conn, %{"floor_id" => floor_id} = params) do
|
||||
attrs = Map.put(params, "floor_id", floor_id)
|
||||
|
||||
case Rooms.create_room(attrs) do
|
||||
{:ok, room} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(render_room(room))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id} = params) do
|
||||
room = Rooms.get_room!(id)
|
||||
attrs = Map.drop(params, ["id", "floor_id"])
|
||||
|
||||
case Rooms.update_room(room, attrs) do
|
||||
{:ok, updated} ->
|
||||
json(conn, render_room(updated))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id}) do
|
||||
room = Rooms.get_room!(id)
|
||||
{:ok, _} = Rooms.delete_room(room)
|
||||
send_resp(conn, :no_content, "")
|
||||
end
|
||||
|
||||
defp render_room(room) do
|
||||
%{
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
floor_id: room.floor_id,
|
||||
x: room.x,
|
||||
y: room.y,
|
||||
width: room.width,
|
||||
height: room.height
|
||||
}
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, val}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(val))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,193 @@
|
||||
defmodule Localiser.Web.Controllers.SensorController do
|
||||
use Phoenix.Controller, formats: [:json]
|
||||
use OpenApiSpex.ControllerSpecs
|
||||
|
||||
alias Localiser.Domain.Sensors
|
||||
alias Localiser.Localisation.Sensor.Server, as: SensorServer
|
||||
alias Localiser.Web.Schemas
|
||||
|
||||
tags ["Sensors"]
|
||||
security [%{"bearerAuth" => []}]
|
||||
|
||||
operation :index,
|
||||
summary: "List all sensors",
|
||||
responses: [
|
||||
ok: {"Sensor list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Sensor}},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :unplaced,
|
||||
summary: "List sensors not yet assigned to a room",
|
||||
responses: [
|
||||
ok: {"Sensor list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Sensor}},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :show,
|
||||
summary: "Get a sensor",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
ok: {"Sensor", "application/json", Schemas.Sensor},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :update,
|
||||
summary: "Update sensor metadata",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
request_body: {"Sensor params", "application/json", Schemas.SensorUpdateParams},
|
||||
responses: [
|
||||
ok: {"Updated sensor", "application/json", Schemas.Sensor},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :delete,
|
||||
summary: "Delete / unenrol a sensor",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
no_content: "Deleted",
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :place,
|
||||
summary: "Place sensor at a position in a room",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
request_body: {"Place params", "application/json", Schemas.SensorPlaceParams, required: true},
|
||||
responses: [
|
||||
ok: {"Updated sensor", "application/json", Schemas.Sensor},
|
||||
bad_request: {"Missing params", "application/json", Schemas.Error},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :unplace,
|
||||
summary: "Remove sensor from floor layout",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
ok: {"Updated sensor", "application/json", Schemas.Sensor},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :calibration_start,
|
||||
summary: "Begin RSSI calibration",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
request_body: {"Calibration params", "application/json", Schemas.CalibrationStartParams, required: true},
|
||||
responses: [
|
||||
ok: {"Calibration started", "application/json", Schemas.CalibrationStatus},
|
||||
bad_request: {"Missing reference_distance", "application/json", Schemas.Error},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :calibration_stop,
|
||||
summary: "Abort active calibration",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
ok: {"Calibration aborted", "application/json", Schemas.CalibrationStatus},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
def index(conn, _params) do
|
||||
json(conn, Enum.map(Sensors.list_sensors(), &render_sensor/1))
|
||||
end
|
||||
|
||||
def unplaced(conn, _params) do
|
||||
json(conn, Enum.map(Sensors.list_unplaced(), &render_sensor/1))
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
sensor = Sensors.get_sensor!(id)
|
||||
json(conn, render_sensor(sensor))
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id} = params) do
|
||||
sensor = Sensors.get_sensor!(id)
|
||||
attrs = Map.drop(params, ["id"])
|
||||
|
||||
case Sensors.update_sensor(sensor, attrs) do
|
||||
{:ok, updated} ->
|
||||
json(conn, render_sensor(updated))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id}) do
|
||||
sensor = Sensors.get_sensor!(id)
|
||||
{:ok, _} = Sensors.delete_sensor(sensor)
|
||||
send_resp(conn, :no_content, "")
|
||||
end
|
||||
|
||||
def place(conn, %{"id" => id, "room_id" => room_id, "x" => x, "y" => y}) do
|
||||
sensor = Sensors.get_sensor!(id)
|
||||
|
||||
case Sensors.place_sensor(sensor, room_id, {x, y}) do
|
||||
{:ok, updated} ->
|
||||
json(conn, render_sensor(updated))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def place(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "room_id, x, and y are required"})
|
||||
end
|
||||
|
||||
def unplace(conn, %{"id" => id}) do
|
||||
sensor = Sensors.get_sensor!(id)
|
||||
|
||||
case Sensors.remove_from_layout(sensor) do
|
||||
{:ok, updated} ->
|
||||
json(conn, render_sensor(updated))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def calibration_start(conn, %{"id" => id, "reference_distance" => ref_dist}) do
|
||||
sensor = Sensors.get_sensor!(id)
|
||||
:ok = SensorServer.begin_calibration(sensor.sensor_id, ref_dist)
|
||||
json(conn, %{status: "calibrating"})
|
||||
end
|
||||
|
||||
def calibration_start(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "reference_distance is required"})
|
||||
end
|
||||
|
||||
def calibration_stop(conn, %{"id" => id}) do
|
||||
sensor = Sensors.get_sensor!(id)
|
||||
:ok = SensorServer.abort_calibration(sensor.sensor_id)
|
||||
json(conn, %{status: "idle"})
|
||||
end
|
||||
|
||||
defp render_sensor(sensor) do
|
||||
%{
|
||||
id: sensor.id,
|
||||
sensor_id: sensor.sensor_id,
|
||||
room_id: sensor.room_id,
|
||||
floor_x: sensor.floor_x,
|
||||
floor_y: sensor.floor_y,
|
||||
rssi_ref: sensor.rssi_ref
|
||||
}
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, val}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(val))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
defmodule Localiser.Web.Controllers.SessionController do
|
||||
use Phoenix.Controller, formats: [:json]
|
||||
use OpenApiSpex.ControllerSpecs
|
||||
|
||||
alias Localiser.Domain.Users
|
||||
alias Localiser.Web.Token
|
||||
alias Localiser.Web.Schemas
|
||||
|
||||
tags ["Auth"]
|
||||
security []
|
||||
|
||||
operation :create,
|
||||
summary: "Log in and receive a JWT",
|
||||
request_body: {"Login params", "application/json", Schemas.SessionParams, required: true},
|
||||
responses: [
|
||||
ok: {"JWT + user", "application/json", Schemas.TokenResponse},
|
||||
bad_request: {"Missing username/password", "application/json", Schemas.Error},
|
||||
unauthorized: {"Invalid credentials", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :delete,
|
||||
summary: "Log out (client discards token)",
|
||||
responses: [no_content: "Logged out"]
|
||||
|
||||
def create(conn, %{"username" => username, "password" => password}) do
|
||||
case Users.authenticate_user(username, password) do
|
||||
{:ok, user} ->
|
||||
token = Token.generate(%{"sub" => user.id})
|
||||
json(conn, %{token: token, user: render_user(user)})
|
||||
|
||||
{:error, :invalid_credentials} ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "invalid credentials"})
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "username and password are required"})
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
# JWT is stateless; client discards token
|
||||
send_resp(conn, :no_content, "")
|
||||
end
|
||||
|
||||
defp render_user(user) do
|
||||
%{id: user.id, username: user.username, is_admin: user.is_admin}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
defmodule Localiser.Web.Controllers.SetupController do
|
||||
use Phoenix.Controller, formats: [:json]
|
||||
use OpenApiSpex.ControllerSpecs
|
||||
|
||||
alias Localiser.Domain.Users
|
||||
alias Localiser.Web.Token
|
||||
alias Localiser.Web.Schemas
|
||||
|
||||
tags ["Auth"]
|
||||
|
||||
operation :create,
|
||||
summary: "Create first admin user",
|
||||
description: "Only succeeds when no users exist. Blocked with 403 afterwards.",
|
||||
security: [],
|
||||
request_body: {"Setup params", "application/json", Schemas.SetupParams, required: true},
|
||||
responses: [
|
||||
created: {"Admin user created", "application/json", Schemas.TokenResponse},
|
||||
bad_request: {"Missing username/password", "application/json", Schemas.Error},
|
||||
forbidden: {"System already initialised", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
def create(conn, %{"username" => username, "password" => password}) do
|
||||
attrs = %{"username" => username, "password" => password, "is_admin" => true}
|
||||
|
||||
case Users.create_user(attrs) do
|
||||
{:ok, user} ->
|
||||
token = Token.generate(%{"sub" => user.id})
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(%{token: token, user: render_user(user)})
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "username and password are required"})
|
||||
end
|
||||
|
||||
defp render_user(user) do
|
||||
%{id: user.id, username: user.username, is_admin: user.is_admin}
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, val}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(val))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
defmodule Localiser.Web.Controllers.TagController do
|
||||
use Phoenix.Controller, formats: [:json]
|
||||
use OpenApiSpex.ControllerSpecs
|
||||
|
||||
alias Localiser.Domain.Tags
|
||||
alias Localiser.Web.Schemas
|
||||
|
||||
tags ["Tags"]
|
||||
security [%{"bearerAuth" => []}]
|
||||
|
||||
operation :index,
|
||||
summary: "List all tags",
|
||||
responses: [
|
||||
ok: {"Tag list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Tag}},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :show,
|
||||
summary: "Get a tag",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
ok: {"Tag", "application/json", Schemas.Tag},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :create,
|
||||
summary: "Enroll a tag",
|
||||
request_body: {"Tag params", "application/json", Schemas.TagParams, required: true},
|
||||
responses: [
|
||||
created: {"Enrolled tag", "application/json", Schemas.Tag},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :update,
|
||||
summary: "Update a tag",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
request_body: {"Tag params", "application/json", Schemas.TagUpdateParams},
|
||||
responses: [
|
||||
ok: {"Updated tag", "application/json", Schemas.Tag},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :delete,
|
||||
summary: "Delete / unenroll a tag",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
no_content: "Deleted",
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
def index(conn, _params) do
|
||||
json(conn, Enum.map(Tags.list_tags(), &render_tag/1))
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
tag = Tags.get_tag!(id)
|
||||
json(conn, render_tag(tag))
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
case Tags.create_tag(params) do
|
||||
{:ok, tag} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(render_tag(tag))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id} = params) do
|
||||
tag = Tags.get_tag!(id)
|
||||
attrs = Map.drop(params, ["id"])
|
||||
|
||||
case Tags.update_tag(tag, attrs) do
|
||||
{:ok, updated} ->
|
||||
json(conn, render_tag(updated))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id}) do
|
||||
tag = Tags.get_tag!(id)
|
||||
{:ok, _} = Tags.delete_tag(tag)
|
||||
send_resp(conn, :no_content, "")
|
||||
end
|
||||
|
||||
defp render_tag(tag) do
|
||||
%{
|
||||
id: tag.id,
|
||||
tag_id: tag.tag_id,
|
||||
name: tag.name
|
||||
}
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, val}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(val))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,147 @@
|
||||
defmodule Localiser.Web.Controllers.UserController do
|
||||
use Phoenix.Controller, formats: [:json]
|
||||
use OpenApiSpex.ControllerSpecs
|
||||
|
||||
alias Localiser.Domain.Users
|
||||
alias Localiser.Web.Schemas
|
||||
|
||||
tags ["Users"]
|
||||
security [%{"bearerAuth" => []}]
|
||||
|
||||
operation :me,
|
||||
summary: "Get own profile",
|
||||
responses: [
|
||||
ok: {"Current user", "application/json", Schemas.User},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :index,
|
||||
summary: "List all users (admin)",
|
||||
responses: [
|
||||
ok: {"User list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.User}},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
forbidden: {"Forbidden", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :show,
|
||||
summary: "Get a user (admin)",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
ok: {"User", "application/json", Schemas.User},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
forbidden: {"Forbidden", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :create,
|
||||
summary: "Create a user (admin)",
|
||||
request_body: {"User params", "application/json", Schemas.UserCreateParams, required: true},
|
||||
responses: [
|
||||
created: {"Created user", "application/json", Schemas.User},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
forbidden: {"Forbidden", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :update,
|
||||
summary: "Update a user (admin)",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
request_body: {"User params", "application/json", Schemas.UserUpdateParams},
|
||||
responses: [
|
||||
ok: {"Updated user", "application/json", Schemas.User},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
forbidden: {"Forbidden", "application/json", Schemas.Error},
|
||||
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
|
||||
]
|
||||
|
||||
operation :delete,
|
||||
summary: "Delete a user (admin)",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
no_content: "Deleted",
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
forbidden: {"Forbidden", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
operation :promote,
|
||||
summary: "Promote a user to admin",
|
||||
parameters: [id: [in: :path, type: :integer, required: true]],
|
||||
responses: [
|
||||
ok: {"Updated user", "application/json", Schemas.User},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
|
||||
forbidden: {"Forbidden", "application/json", Schemas.Error}
|
||||
]
|
||||
|
||||
def me(conn, _params) do
|
||||
json(conn, render_user(conn.assigns.current_user))
|
||||
end
|
||||
|
||||
def index(conn, _params) do
|
||||
json(conn, Enum.map(Users.list_users(), &render_user/1))
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
user = Users.get_user!(id)
|
||||
json(conn, render_user(user))
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
case Users.create_user(params) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> json(render_user(user))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id} = params) do
|
||||
user = Users.get_user!(id)
|
||||
attrs = Map.drop(params, ["id"])
|
||||
|
||||
case Users.update_user(user, attrs) do
|
||||
{:ok, updated} ->
|
||||
json(conn, render_user(updated))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id}) do
|
||||
user = Users.get_user!(id)
|
||||
{:ok, _} = Users.delete_user(user)
|
||||
send_resp(conn, :no_content, "")
|
||||
end
|
||||
|
||||
def promote(conn, %{"id" => id}) do
|
||||
user = Users.get_user!(id)
|
||||
|
||||
case Users.promote_to_admin(user) do
|
||||
{:ok, updated} ->
|
||||
json(conn, render_user(updated))
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{errors: format_errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
defp render_user(user) do
|
||||
%{id: user.id, username: user.username, is_admin: user.is_admin}
|
||||
end
|
||||
|
||||
defp format_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, val}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(val))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
defmodule Localiser.Web.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :localiserd
|
||||
|
||||
socket "/socket", Localiser.Web.UserSocket,
|
||||
websocket: true,
|
||||
longpoll: false
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Parsers,
|
||||
parsers: [:json],
|
||||
pass: ["application/json"],
|
||||
json_decoder: Jason
|
||||
|
||||
plug OpenApiSpex.Plug.PutApiSpec, module: Localiser.Web.ApiSpec
|
||||
|
||||
plug Localiser.Web.Router
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
defmodule Localiser.ErrorView do
|
||||
def render("404.json", _assigns), do: %{error: "not found"}
|
||||
def render("422.json", _assigns), do: %{error: "unprocessable entity"}
|
||||
def render("500.json", _assigns), do: %{error: "internal server error"}
|
||||
def render(_, _assigns), do: %{error: "error"}
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
defmodule Localiser.Web.Plugs.AdminRequired do
|
||||
import Plug.Conn
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(%{assigns: %{current_user: %{is_admin: true}}} = conn, _opts), do: conn
|
||||
|
||||
def call(conn, _opts) do
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(403, ~s({"error":"admin required"}))
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
defmodule Localiser.Web.Plugs.AuthRequired do
|
||||
import Plug.Conn
|
||||
|
||||
alias Localiser.Web.Token
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
|
||||
{:ok, claims} <- Token.verify_token(token) do
|
||||
assign(conn, :current_user, %{
|
||||
user_id: claims["user_id"],
|
||||
username: claims["username"],
|
||||
is_admin: claims["is_admin"]
|
||||
})
|
||||
else
|
||||
_ ->
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(401, ~s({"error":"unauthorised"}))
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
defmodule Localiser.Web.Plugs.BootstrapGuard do
|
||||
@moduledoc "Halts with 403 if any users already exist - protects POST /api/setup."
|
||||
import Plug.Conn
|
||||
|
||||
alias Localiser.Domain.Users
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
if Users.any?() do
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(403, ~s({"error":"system already initialised"}))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
defmodule Localiser.Web.Router do
|
||||
use Phoenix.Router, helpers: false
|
||||
|
||||
alias Localiser.Web.Plugs.{AuthRequired, AdminRequired, BootstrapGuard}
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
pipeline :authenticated do
|
||||
plug :accepts, ["json"]
|
||||
plug AuthRequired
|
||||
end
|
||||
|
||||
pipeline :admin do
|
||||
plug :accepts, ["json"]
|
||||
plug AuthRequired
|
||||
plug AdminRequired
|
||||
end
|
||||
|
||||
pipeline :bootstrap do
|
||||
plug :accepts, ["json"]
|
||||
plug BootstrapGuard
|
||||
end
|
||||
|
||||
# OpenAPI spec (unauthenticated)
|
||||
scope "/api" do
|
||||
pipe_through :api
|
||||
get "/openapi", OpenApiSpex.Plug.RenderSpec, []
|
||||
end
|
||||
|
||||
# First-boot setup - forbidden once any user exists
|
||||
scope "/api", Localiser.Web.Controllers do
|
||||
pipe_through :bootstrap
|
||||
post "/setup", SetupController, :create
|
||||
end
|
||||
|
||||
# Auth - public
|
||||
scope "/api", Localiser.Web.Controllers do
|
||||
pipe_through :api
|
||||
post "/session", SessionController, :create
|
||||
delete "/session", SessionController, :delete
|
||||
end
|
||||
|
||||
# Onboarding status - public
|
||||
scope "/api", Localiser.Web.Controllers do
|
||||
pipe_through :api
|
||||
get "/onboarding", OnboardingController, :status
|
||||
end
|
||||
|
||||
# User self-service (show own profile)
|
||||
scope "/api", Localiser.Web.Controllers do
|
||||
pipe_through :authenticated
|
||||
get "/users/me", UserController, :me
|
||||
end
|
||||
|
||||
# User admin CRUD
|
||||
scope "/api", Localiser.Web.Controllers do
|
||||
pipe_through :admin
|
||||
get "/users", UserController, :index
|
||||
get "/users/:id", UserController, :show
|
||||
post "/users", UserController, :create
|
||||
put "/users/:id", UserController, :update
|
||||
delete "/users/:id", UserController, :delete
|
||||
put "/users/:id/admin", UserController, :promote
|
||||
end
|
||||
|
||||
# Floors / Rooms / Tags - auth required
|
||||
scope "/api", Localiser.Web.Controllers do
|
||||
pipe_through :authenticated
|
||||
|
||||
resources "/floors", FloorController, except: [:new, :edit]
|
||||
resources "/floors/:floor_id/rooms", RoomController, except: [:new, :edit]
|
||||
resources "/tags", TagController, except: [:new, :edit]
|
||||
|
||||
# Sensors
|
||||
get "/sensors", SensorController, :index
|
||||
get "/sensors/unplaced", SensorController, :unplaced
|
||||
get "/sensors/:id", SensorController, :show
|
||||
put "/sensors/:id", SensorController, :update
|
||||
delete "/sensors/:id", SensorController, :delete
|
||||
put "/sensors/:id/place", SensorController, :place
|
||||
delete "/sensors/:id/place", SensorController, :unplace
|
||||
post "/sensors/:id/calibration/start", SensorController, :calibration_start
|
||||
post "/sensors/:id/calibration/stop", SensorController, :calibration_stop
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,293 @@
|
||||
defmodule Localiser.Web.Schemas do
|
||||
alias OpenApiSpex.Schema
|
||||
|
||||
defmodule User do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "User", type: :object,
|
||||
properties: %{
|
||||
id: %Schema{type: :integer},
|
||||
username: %Schema{type: :string},
|
||||
is_admin: %Schema{type: :boolean}
|
||||
},
|
||||
required: [:id, :username, :is_admin]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule Floor do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "Floor", type: :object,
|
||||
properties: %{
|
||||
id: %Schema{type: :integer},
|
||||
name: %Schema{type: :string},
|
||||
width: %Schema{type: :number, format: :float},
|
||||
height: %Schema{type: :number, format: :float}
|
||||
},
|
||||
required: [:id, :name, :width, :height]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule Room do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "Room", type: :object,
|
||||
properties: %{
|
||||
id: %Schema{type: :integer},
|
||||
name: %Schema{type: :string},
|
||||
floor_id: %Schema{type: :integer},
|
||||
x: %Schema{type: :number, format: :float},
|
||||
y: %Schema{type: :number, format: :float},
|
||||
width: %Schema{type: :number, format: :float},
|
||||
height: %Schema{type: :number, format: :float}
|
||||
},
|
||||
required: [:id, :name, :floor_id, :x, :y, :width, :height]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule Sensor do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "Sensor", type: :object,
|
||||
properties: %{
|
||||
id: %Schema{type: :integer},
|
||||
sensor_id: %Schema{type: :string},
|
||||
room_id: %Schema{type: :integer, nullable: true},
|
||||
floor_x: %Schema{type: :number, format: :float, nullable: true},
|
||||
floor_y: %Schema{type: :number, format: :float, nullable: true},
|
||||
rssi_ref: %Schema{type: :number, format: :float, nullable: true}
|
||||
},
|
||||
required: [:id, :sensor_id]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule Tag do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "Tag", type: :object,
|
||||
properties: %{
|
||||
id: %Schema{type: :integer},
|
||||
tag_id: %Schema{type: :string},
|
||||
name: %Schema{type: :string}
|
||||
},
|
||||
required: [:id, :tag_id, :name]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule OnboardingStatus do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "OnboardingStatus", type: :object,
|
||||
properties: %{
|
||||
has_admin: %Schema{type: :boolean},
|
||||
has_floors: %Schema{type: :boolean},
|
||||
has_rooms: %Schema{type: :boolean},
|
||||
has_sensors_placed: %Schema{type: :boolean},
|
||||
has_tags: %Schema{type: :boolean}
|
||||
},
|
||||
required: [:has_admin, :has_floors, :has_rooms, :has_sensors_placed, :has_tags]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule TokenResponse do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "TokenResponse", type: :object,
|
||||
properties: %{
|
||||
token: %Schema{type: :string},
|
||||
user: User
|
||||
},
|
||||
required: [:token, :user]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule Error do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "Error", type: :object,
|
||||
properties: %{error: %Schema{type: :string}},
|
||||
required: [:error]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule ValidationErrors do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "ValidationErrors", type: :object,
|
||||
properties: %{
|
||||
errors: %Schema{
|
||||
type: :object,
|
||||
additionalProperties: %Schema{type: :array, items: %Schema{type: :string}}
|
||||
}
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
defmodule CalibrationStatus do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "CalibrationStatus", type: :object,
|
||||
properties: %{status: %Schema{type: :string, enum: ["calibrating", "idle"]}},
|
||||
required: [:status]
|
||||
})
|
||||
end
|
||||
|
||||
# ── Request body schemas ────────────────────────────────────────────────────
|
||||
|
||||
defmodule SetupParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "SetupParams", type: :object,
|
||||
properties: %{
|
||||
username: %Schema{type: :string},
|
||||
password: %Schema{type: :string, format: :password}
|
||||
},
|
||||
required: [:username, :password]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule SessionParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "SessionParams", type: :object,
|
||||
properties: %{
|
||||
username: %Schema{type: :string},
|
||||
password: %Schema{type: :string, format: :password}
|
||||
},
|
||||
required: [:username, :password]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule UserCreateParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "UserCreateParams", type: :object,
|
||||
properties: %{
|
||||
username: %Schema{type: :string},
|
||||
password: %Schema{type: :string, format: :password},
|
||||
is_admin: %Schema{type: :boolean, default: false}
|
||||
},
|
||||
required: [:username, :password]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule UserUpdateParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "UserUpdateParams", type: :object,
|
||||
properties: %{
|
||||
username: %Schema{type: :string},
|
||||
password: %Schema{type: :string, format: :password}
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
defmodule FloorParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "FloorParams", type: :object,
|
||||
properties: %{
|
||||
name: %Schema{type: :string},
|
||||
width: %Schema{type: :number, format: :float},
|
||||
height: %Schema{type: :number, format: :float}
|
||||
},
|
||||
required: [:name, :width, :height]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule FloorUpdateParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "FloorUpdateParams", type: :object,
|
||||
properties: %{
|
||||
name: %Schema{type: :string},
|
||||
width: %Schema{type: :number, format: :float},
|
||||
height: %Schema{type: :number, format: :float}
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
defmodule RoomParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "RoomParams", type: :object,
|
||||
properties: %{
|
||||
name: %Schema{type: :string},
|
||||
x: %Schema{type: :number, format: :float},
|
||||
y: %Schema{type: :number, format: :float},
|
||||
width: %Schema{type: :number, format: :float},
|
||||
height: %Schema{type: :number, format: :float}
|
||||
},
|
||||
required: [:name, :x, :y, :width, :height]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule RoomUpdateParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "RoomUpdateParams", type: :object,
|
||||
properties: %{
|
||||
name: %Schema{type: :string},
|
||||
x: %Schema{type: :number, format: :float},
|
||||
y: %Schema{type: :number, format: :float},
|
||||
width: %Schema{type: :number, format: :float},
|
||||
height: %Schema{type: :number, format: :float}
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
defmodule SensorUpdateParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "SensorUpdateParams", type: :object,
|
||||
properties: %{rssi_ref: %Schema{type: :number, format: :float}}
|
||||
})
|
||||
end
|
||||
|
||||
defmodule SensorPlaceParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "SensorPlaceParams", type: :object,
|
||||
properties: %{
|
||||
room_id: %Schema{type: :integer},
|
||||
x: %Schema{type: :number, format: :float},
|
||||
y: %Schema{type: :number, format: :float}
|
||||
},
|
||||
required: [:room_id, :x, :y]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule CalibrationStartParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "CalibrationStartParams", type: :object,
|
||||
properties: %{
|
||||
reference_distance: %Schema{
|
||||
type: :number, format: :float,
|
||||
description: "Known tag-to-sensor distance in metres"
|
||||
}
|
||||
},
|
||||
required: [:reference_distance]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule TagParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "TagParams", type: :object,
|
||||
properties: %{
|
||||
tag_id: %Schema{type: :string, description: "BLE MAC address or device ID"},
|
||||
name: %Schema{type: :string}
|
||||
},
|
||||
required: [:tag_id, :name]
|
||||
})
|
||||
end
|
||||
|
||||
defmodule TagUpdateParams do
|
||||
require OpenApiSpex
|
||||
OpenApiSpex.schema(%{
|
||||
title: "TagUpdateParams", type: :object,
|
||||
properties: %{name: %Schema{type: :string}}
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
defmodule Localiser.Web.Token do
|
||||
use Joken.Config
|
||||
|
||||
@ttl 86_400 # 24 hours in seconds
|
||||
|
||||
@impl true
|
||||
def token_config do
|
||||
default_claims(
|
||||
iss: "localiserd",
|
||||
default_exp: @ttl
|
||||
)
|
||||
end
|
||||
|
||||
def generate(claims) do
|
||||
secret = Application.fetch_env!(:localiserd, :jwt_secret)
|
||||
signer = Joken.Signer.create("HS256", secret)
|
||||
|
||||
case generate_and_sign(claims, signer) do
|
||||
{:ok, token, _claims} -> token
|
||||
{:error, reason} -> raise "Token generation failed: #{inspect(reason)}"
|
||||
end
|
||||
end
|
||||
|
||||
def verify_token(token) do
|
||||
secret = Application.fetch_env!(:localiserd, :jwt_secret)
|
||||
signer = Joken.Signer.create("HS256", secret)
|
||||
verify_and_validate(token, signer)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
defmodule Localiser.Web.UserSocket do
|
||||
use Phoenix.Socket
|
||||
|
||||
channel "particles:*", Localiser.Web.Channels.ParticlesChannel
|
||||
channel "rooms:occupancy", Localiser.Web.Channels.RoomChannel
|
||||
channel "sensors", Localiser.Web.Channels.SensorsChannel
|
||||
|
||||
@impl true
|
||||
def connect(%{"token" => token}, socket, _connect_info) do
|
||||
case Localiser.Web.Token.verify_token(token) do
|
||||
{:ok, %{"sub" => user_id}} ->
|
||||
case Localiser.Domain.Users.get_user(user_id) do
|
||||
{:ok, user} -> {:ok, assign(socket, :current_user, user)}
|
||||
_ -> :error
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
def connect(_params, _socket, _connect_info), do: :error
|
||||
|
||||
@impl true
|
||||
def id(socket), do: "user_socket:#{socket.assigns.current_user.id}"
|
||||
end
|
||||
@@ -26,7 +26,11 @@ defmodule Localiserd.MixProject do
|
||||
{:jason, "~> 1.4"},
|
||||
{:ecto_sqlite3, "~> 0.18"},
|
||||
{:argon2_elixir, "~> 4.0"},
|
||||
{:phoenix_pubsub, "~> 2.1"}
|
||||
{:phoenix_pubsub, "~> 2.1"},
|
||||
{:phoenix, "~> 1.7"},
|
||||
{:bandit, "~> 1.5"},
|
||||
{:joken, "~> 2.6"},
|
||||
{:open_api_spex, "~> 3.21"}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
%{
|
||||
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
|
||||
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [: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", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
|
||||
@@ -13,7 +14,19 @@
|
||||
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [: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", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
|
||||
"getopt": {:hex, :getopt, "1.0.3", "4f3320c1f6f26b2bec0f6c6446b943eb927a1e6428ea279a1c6c534906ee79f1", [:rebar3], [], "hexpm", "7e01de90ac540f21494ff72792b1e3162d399966ebbfc674b4ce52cb8f49324f"},
|
||||
"gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"},
|
||||
"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"},
|
||||
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
||||
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"open_api_spex": {:hex, :open_api_spex, "3.22.2", "0b3c4f572ee69cb6c936abf426b9d84d8eebd34960871fd77aead746f0d69cb0", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "0a4fc08472d75e9cfe96e0748c6b1565b3b4398f97bf43fcce41b41b6fd3fb33"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"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"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user