diff --git a/lib/scopes/storage.ex b/lib/scopes/storage.ex new file mode 100644 index 0000000..0ec722a --- /dev/null +++ b/lib/scopes/storage.ex @@ -0,0 +1,104 @@ +defmodule Scopes.Storage do + @moduledoc """ + The Storage context. + """ + + import Ecto.Query, warn: false + alias Scopes.Repo + + alias Scopes.Storage.Concept + + @doc """ + Returns the list of concepts. + + ## Examples + + iex> list_concepts() + [%Concept{}, ...] + + """ + def list_concepts do + Repo.all(Concept) + end + + @doc """ + Gets a single concept. + + Raises `Ecto.NoResultsError` if the Concept does not exist. + + ## Examples + + iex> get_concept!(123) + %Concept{} + + iex> get_concept!(456) + ** (Ecto.NoResultsError) + + """ + def get_concept!(id), do: Repo.get!(Concept, id) + + @doc """ + Creates a concept. + + ## Examples + + iex> create_concept(%{field: value}) + {:ok, %Concept{}} + + iex> create_concept(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_concept(attrs) do + %Concept{} + |> Concept.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a concept. + + ## Examples + + iex> update_concept(concept, %{field: new_value}) + {:ok, %Concept{}} + + iex> update_concept(concept, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_concept(%Concept{} = concept, attrs) do + concept + |> Concept.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a concept. + + ## Examples + + iex> delete_concept(concept) + {:ok, %Concept{}} + + iex> delete_concept(concept) + {:error, %Ecto.Changeset{}} + + """ + def delete_concept(%Concept{} = concept) do + Repo.delete(concept) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking concept changes. + + ## Examples + + iex> change_concept(concept) + %Ecto.Changeset{data: %Concept{}} + + """ + def change_concept(%Concept{} = concept, attrs \\ %{}) do + Concept.changeset(concept, attrs) + end +end diff --git a/lib/scopes/storage/concept.ex b/lib/scopes/storage/concept.ex new file mode 100644 index 0000000..88860f1 --- /dev/null +++ b/lib/scopes/storage/concept.ex @@ -0,0 +1,20 @@ +defmodule Scopes.Storage.Concept do + use Ecto.Schema + import Ecto.Changeset + + schema "concepts" do + field :domain, :string + field :class, :string + field :item, :string + field :data, :map + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(concept, attrs) do + concept + |> cast(attrs, [:domain, :class, :item, :data]) + |> validate_required([:domain, :class, :item]) + end +end diff --git a/lib/scopes_web/controllers/changeset_json.ex b/lib/scopes_web/controllers/changeset_json.ex new file mode 100644 index 0000000..e91a465 --- /dev/null +++ b/lib/scopes_web/controllers/changeset_json.ex @@ -0,0 +1,25 @@ +defmodule ScopesWeb.ChangesetJSON do + @doc """ + Renders changeset errors. + """ + def error(%{changeset: changeset}) do + # When encoded, the changeset returns its errors + # as a JSON object. So we just pass it forward. + %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)} + end + + defp translate_error({msg, opts}) do + # You can make use of gettext to translate error messages by + # uncommenting and adjusting the following code: + + # if count = opts[:count] do + # Gettext.dngettext(ScopesWeb.Gettext, "errors", msg, msg, count, opts) + # else + # Gettext.dgettext(ScopesWeb.Gettext, "errors", msg, opts) + # end + + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end +end diff --git a/lib/scopes_web/controllers/concept_controller.ex b/lib/scopes_web/controllers/concept_controller.ex new file mode 100644 index 0000000..2f60c15 --- /dev/null +++ b/lib/scopes_web/controllers/concept_controller.ex @@ -0,0 +1,43 @@ +defmodule ScopesWeb.ConceptController do + use ScopesWeb, :controller + + alias Scopes.Storage + alias Scopes.Storage.Concept + + action_fallback ScopesWeb.FallbackController + + def index(conn, _params) do + concepts = Storage.list_concepts() + render(conn, :index, concepts: concepts) + end + + def create(conn, %{"concept" => concept_params}) do + with {:ok, %Concept{} = concept} <- Storage.create_concept(concept_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/concepts/#{concept}") + |> render(:show, concept: concept) + end + end + + def show(conn, %{"id" => id}) do + concept = Storage.get_concept!(id) + render(conn, :show, concept: concept) + end + + def update(conn, %{"id" => id, "concept" => concept_params}) do + concept = Storage.get_concept!(id) + + with {:ok, %Concept{} = concept} <- Storage.update_concept(concept, concept_params) do + render(conn, :show, concept: concept) + end + end + + def delete(conn, %{"id" => id}) do + concept = Storage.get_concept!(id) + + with {:ok, %Concept{}} <- Storage.delete_concept(concept) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/scopes_web/controllers/concept_json.ex b/lib/scopes_web/controllers/concept_json.ex new file mode 100644 index 0000000..3f0273a --- /dev/null +++ b/lib/scopes_web/controllers/concept_json.ex @@ -0,0 +1,27 @@ +defmodule ScopesWeb.ConceptJSON do + alias Scopes.Storage.Concept + + @doc """ + Renders a list of concepts. + """ + def index(%{concepts: concepts}) do + %{data: for(concept <- concepts, do: data(concept))} + end + + @doc """ + Renders a single concept. + """ + def show(%{concept: concept}) do + %{data: data(concept)} + end + + defp data(%Concept{} = concept) do + %{ + id: concept.id, + domain: concept.domain, + class: concept.class, + item: concept.item, + data: concept.data + } + end +end diff --git a/lib/scopes_web/controllers/fallback_controller.ex b/lib/scopes_web/controllers/fallback_controller.ex new file mode 100644 index 0000000..e5c1d49 --- /dev/null +++ b/lib/scopes_web/controllers/fallback_controller.ex @@ -0,0 +1,24 @@ +defmodule ScopesWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use ScopesWeb, :controller + + # This clause handles errors returned by Ecto's insert/update/delete. + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: ScopesWeb.ChangesetJSON) + |> render(:error, changeset: changeset) + end + + # This clause is an example of how to handle resources that cannot be found. + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(html: ScopesWeb.ErrorHTML, json: ScopesWeb.ErrorJSON) + |> render(:"404") + end +end diff --git a/lib/scopes_web/router.ex b/lib/scopes_web/router.ex index 31d4e8f..5446905 100644 --- a/lib/scopes_web/router.ex +++ b/lib/scopes_web/router.ex @@ -26,9 +26,10 @@ defmodule ScopesWeb.Router do end # Other scopes may use custom stacks. - # scope "/api", ScopesWeb do - # pipe_through :api - # end + scope "/api", ScopesWeb do + pipe_through :api + resources "/concepts", ConceptController, except: [:new, :edit] + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:scopes, :dev_routes) do diff --git a/priv/repo/migrations/20260530121444_create_concepts.exs b/priv/repo/migrations/20260530121444_create_concepts.exs new file mode 100644 index 0000000..4e0904d --- /dev/null +++ b/priv/repo/migrations/20260530121444_create_concepts.exs @@ -0,0 +1,14 @@ +defmodule Scopes.Repo.Migrations.CreateConcepts do + use Ecto.Migration + + def change do + create table(:concepts) do + add :domain, :string + add :class, :string + add :item, :string + add :data, :map + + timestamps(type: :utc_datetime) + end + end +end diff --git a/test/scopes/storage_test.exs b/test/scopes/storage_test.exs new file mode 100644 index 0000000..8b5066f --- /dev/null +++ b/test/scopes/storage_test.exs @@ -0,0 +1,65 @@ +defmodule Scopes.StorageTest do + use Scopes.DataCase + + alias Scopes.Storage + + describe "concepts" do + alias Scopes.Storage.Concept + + import Scopes.StorageFixtures + + @invalid_attrs %{data: nil, domain: nil, item: nil, class: nil} + + test "list_concepts/0 returns all concepts" do + concept = concept_fixture() + assert Storage.list_concepts() == [concept] + end + + test "get_concept!/1 returns the concept with given id" do + concept = concept_fixture() + assert Storage.get_concept!(concept.id) == concept + end + + test "create_concept/1 with valid data creates a concept" do + valid_attrs = %{data: %{}, domain: "some domain", item: "some item", class: "some class"} + + assert {:ok, %Concept{} = concept} = Storage.create_concept(valid_attrs) + assert concept.data == %{} + assert concept.domain == "some domain" + assert concept.item == "some item" + assert concept.class == "some class" + end + + test "create_concept/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Storage.create_concept(@invalid_attrs) + end + + test "update_concept/2 with valid data updates the concept" do + concept = concept_fixture() + update_attrs = %{data: %{}, domain: "some updated domain", item: "some updated item", class: "some updated class"} + + assert {:ok, %Concept{} = concept} = Storage.update_concept(concept, update_attrs) + assert concept.data == %{} + assert concept.domain == "some updated domain" + assert concept.item == "some updated item" + assert concept.class == "some updated class" + end + + test "update_concept/2 with invalid data returns error changeset" do + concept = concept_fixture() + assert {:error, %Ecto.Changeset{}} = Storage.update_concept(concept, @invalid_attrs) + assert concept == Storage.get_concept!(concept.id) + end + + test "delete_concept/1 deletes the concept" do + concept = concept_fixture() + assert {:ok, %Concept{}} = Storage.delete_concept(concept) + assert_raise Ecto.NoResultsError, fn -> Storage.get_concept!(concept.id) end + end + + test "change_concept/1 returns a concept changeset" do + concept = concept_fixture() + assert %Ecto.Changeset{} = Storage.change_concept(concept) + end + end +end diff --git a/test/scopes_web/controllers/concept_controller_test.exs b/test/scopes_web/controllers/concept_controller_test.exs new file mode 100644 index 0000000..a4ee868 --- /dev/null +++ b/test/scopes_web/controllers/concept_controller_test.exs @@ -0,0 +1,96 @@ +defmodule ScopesWeb.ConceptControllerTest do + use ScopesWeb.ConnCase + + import Scopes.StorageFixtures + alias Scopes.Storage.Concept + + @create_attrs %{ + data: %{}, + domain: "some domain", + item: "some item", + class: "some class" + } + @update_attrs %{ + data: %{}, + domain: "some updated domain", + item: "some updated item", + class: "some updated class" + } + @invalid_attrs %{data: nil, domain: nil, item: nil, class: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all concepts", %{conn: conn} do + conn = get(conn, ~p"/api/concepts") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create concept" do + test "renders concept when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/concepts", concept: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/concepts/#{id}") + + assert %{ + "id" => ^id, + "class" => "some class", + "data" => %{}, + "domain" => "some domain", + "item" => "some item" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/concepts", concept: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update concept" do + setup [:create_concept] + + test "renders concept when data is valid", %{conn: conn, concept: %Concept{id: id} = concept} do + conn = put(conn, ~p"/api/concepts/#{concept}", concept: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/concepts/#{id}") + + assert %{ + "id" => ^id, + "class" => "some updated class", + "data" => %{}, + "domain" => "some updated domain", + "item" => "some updated item" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, concept: concept} do + conn = put(conn, ~p"/api/concepts/#{concept}", concept: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete concept" do + setup [:create_concept] + + test "deletes chosen concept", %{conn: conn, concept: concept} do + conn = delete(conn, ~p"/api/concepts/#{concept}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/concepts/#{concept}") + end + end + end + + defp create_concept(_) do + concept = concept_fixture() + + %{concept: concept} + end +end diff --git a/test/support/fixtures/storage_fixtures.ex b/test/support/fixtures/storage_fixtures.ex new file mode 100644 index 0000000..ff0ddf7 --- /dev/null +++ b/test/support/fixtures/storage_fixtures.ex @@ -0,0 +1,23 @@ +defmodule Scopes.StorageFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Scopes.Storage` context. + """ + + @doc """ + Generate a concept. + """ + def concept_fixture(attrs \\ %{}) do + {:ok, concept} = + attrs + |> Enum.into(%{ + class: "some class", + data: %{}, + domain: "some domain", + item: "some item" + }) + |> Scopes.Storage.create_concept() + + concept + end +end