Skip to content

Commit

Permalink
Allow to edit a source
Browse files Browse the repository at this point in the history
  • Loading branch information
Loïc Knuchel committed Nov 11, 2023
1 parent f899b13 commit 1d0effd
Show file tree
Hide file tree
Showing 12 changed files with 315 additions and 24 deletions.
16 changes: 16 additions & 0 deletions backend/lib/azimutt/utils/mapx.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ defmodule Azimutt.Utils.Mapx do
"""
def map_values(enumerable, f), do: enumerable |> Enum.map(fn {k, v} -> {k, f.(v)} end) |> Map.new()

@doc """
Same as `put` but if value is `nil` it removes the key.
## Examples
iex> %{foo: "bar", bob: "alice"} |> Mapx.put_no_nil(:bob, "claude")
%{foo: "bar", bob: "claude"}
iex> %{foo: "bar", bob: "alice"} |> Mapx.put_no_nil(:bob, nil)
%{foo: "bar"}
"""
def put_no_nil(enumerable, key, value) do
if value == nil do
enumerable |> Map.delete(key)
else
enumerable |> Map.put(key, value)
end
end

@doc """
Remove a key if it's present with the expected value, or set it
## Examples
Expand Down
52 changes: 52 additions & 0 deletions backend/lib/azimutt/utils/result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,58 @@ defmodule Azimutt.Utils.Result do
def flat_map_with({:ok, val}, f), do: f.(val) |> map(fn r -> {val, r} end)
def flat_map_with({:error, _err} = res, _f), do: res

@doc """
Associate the Result value with the given value in a tuple.
## Examples
iex> {:ok, 1} |> Result.zip(2)
{:ok, {1, 2}}
iex> {:error, 1} |> Result.zip(2)
{:error, 1}
"""
def zip(:ok, v), do: ok({nil, v})
def zip(:error = res, _v), do: res
def zip({:ok, val}, v), do: ok({val, v})
def zip({:error, _err} = res, _v), do: res

@doc """
Same as `zip` but in the reverse order for the tuple.
## Examples
iex> {:ok, 1} |> Result.zip_left(2)
{:ok, {2, 1}}
iex> {:error, 1} |> Result.zip_left(2)
{:error, 1}
"""
def zip_left(:ok, v), do: ok({v, nil})
def zip_left(:error = res, _v), do: res
def zip_left({:ok, val}, v), do: ok({v, val})
def zip_left({:error, _err} = res, _v), do: res

@doc """
Same as `zip` but for the error value instead of the success one.
## Examples
iex> {:ok, 1} |> Result.zip_error(2)
{:ok, 1}
iex> {:error, 1} |> Result.zip_error(2)
{:error, {1, 2}}
"""
def zip_error(:ok = res, _v), do: res
def zip_error(:error, v), do: error({nil, v})
def zip_error({:ok, _val} = res, _v), do: res
def zip_error({:error, err}, v), do: error({err, v})

@doc """
Same as `zip_error` but in the reverse order for the tuple.
## Examples
iex> {:ok, 1} |> Result.zip_error_left(2)
{:ok, 1}
iex> {:error, 1} |> Result.zip_error_left(2)
{:error, {2, 1}}
"""
def zip_error_left(:ok = res, _v), do: res
def zip_error_left(:error, v), do: error({v, nil})
def zip_error_left({:ok, _val} = res, _v), do: res
def zip_error_left({:error, err}, v), do: error({v, err})

@doc """
Execute the function on :ok but does not change the result
## Examples
Expand Down
37 changes: 29 additions & 8 deletions backend/lib/azimutt_web/controllers/api/fallback_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule AzimuttWeb.Api.FallbackController do
See `Phoenix.Controller.action_fallback/1` for more details.
"""
use AzimuttWeb, :controller
alias Azimutt.Utils.Result

# This clause handles errors returned by Ecto's insert/update/delete.
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
Expand All @@ -14,33 +15,39 @@ defmodule AzimuttWeb.Api.FallbackController do
|> render("error.json", changeset: changeset)
end

# This clause is an example of how to handle resources that cannot be found.
def call(conn, {:error, :forbidden}) do
conn
|> put_status(:forbidden)
|> put_view(AzimuttWeb.ErrorView)
|> render("error.json", format("Forbidden"))
end

def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(AzimuttWeb.ErrorView)
|> render("error.json", message: "Not Found")
|> render("error.json", format("Not Found"))
end

def call(conn, {:error, :unauthorized}) do
conn
|> put_status(:unauthorized)
|> put_view(AzimuttWeb.ErrorView)
|> render("error.json", message: "Unauthorized")
|> render("error.json", format("Unauthorized"))
end

def call(conn, {:error, {status, message}}) do
conn
|> put_status(status)
|> put_view(AzimuttWeb.ErrorView)
|> render("error.json", message: format(message))
|> render("error.json", format(message))
end

def call(conn, {:error, message}) do
conn
|> put_status(:internal_server_error)
|> put_view(AzimuttWeb.ErrorView)
|> render("error.json", message: format(message))
|> render("error.json", format(message))
end

def call(conn, :ok) do
Expand All @@ -51,9 +58,23 @@ defmodule AzimuttWeb.Api.FallbackController do
conn
|> put_status(:internal_server_error)
|> put_view(AzimuttWeb.ErrorView)
|> render("error.json", message: format(other))
|> render("error.json", format(other))
end

defp format(message) when is_binary(message), do: message
defp format(message), do: inspect(message)
defp format(value) when is_binary(value), do: %{message: value}
defp format(value) when is_map(value), do: value |> Map.put(:message, as_string(value))
defp format(value), do: %{message: as_string(value)}

defp as_string(value) do
cond do
is_binary(value) ->
value

is_atom(value) ->
Atom.to_string(value)

true ->
Jason.encode(value) |> Result.or_else(inspect(value))
end
end
end
55 changes: 49 additions & 6 deletions backend/lib/azimutt_web/controllers/api/source_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ defmodule AzimuttWeb.Api.SourceController do
use PhoenixSwagger
alias Azimutt.Projects
alias Azimutt.Projects.Project
alias Azimutt.Utils.Mapx
alias Azimutt.Utils.Result
alias AzimuttWeb.Utils.CtxParams
alias AzimuttWeb.Utils.ProjectSchema
action_fallback AzimuttWeb.Api.FallbackController

# TODO: add swagger doc
# TODO: remove origin fields in sources
# TODO: remove origin fields in sources (Elm & JS)

def index(conn, %{"organization_id" => _organization_id, "project_id" => project_id} = params) do
current_user = conn.assigns.current_user
Expand All @@ -29,14 +31,39 @@ defmodule AzimuttWeb.Api.SourceController do
do: conn |> render("show.json", source: source, ctx: ctx)
end

def update(conn, %{"organization_id" => _organization_id, "project_id" => _project_id, "source_id" => _source_id} = _params) do
# FIXME, forbid to edit AmlEditor sources
def create(conn, %{"organization_id" => _organization_id, "project_id" => _project_id} = _params) do
# TODO
_create_schema = %{}
conn |> send_resp(:not_found, "WIP")
end

def create(conn, %{"organization_id" => _organization_id, "project_id" => _project_id} = _params) do
# FIXME
conn |> send_resp(:not_found, "WIP")
def update(conn, %{"organization_id" => _organization_id, "project_id" => project_id, "source_id" => source_id} = params) do
now = DateTime.utc_now()
ctx = CtxParams.from_params(params)
current_user = conn.assigns.current_user

update_schema = %{
"type" => "object",
"additionalProperties" => false,
"required" => ["tables", "relations"],
"properties" => %{
"tables" => %{"type" => "array", "items" => ProjectSchema.table()},
"relations" => %{"type" => "array", "items" => ProjectSchema.relation()},
"types" => %{"type" => "array", "items" => ProjectSchema.type()}
},
"definitions" => %{"column" => ProjectSchema.column()}
}

with {:ok, body} <- validate_json_schema(update_schema, conn.body_params) |> Result.zip_error_left(:bad_request),
{:ok, %Project{} = project} <- Projects.get_project(project_id, current_user),
{:ok, content} <- Projects.get_project_content(project),
{:ok, json} <- Jason.decode(content),
{:ok, source} <- json["sources"] |> Enum.find(fn s -> s["id"] == source_id end) |> Result.from_nillable(),
:ok <- if(source["kind"]["kind"] == "AmlEditor", do: {:error, {:forbidden, "AML sources can't be updated."}}, else: :ok),
json_updated = json |> Map.put("sources", json["sources"] |> Enum.map(fn s -> if(s["id"] == source_id, do: update_source(s, body), else: s) end)),
{:ok, content_updated} <- Jason.encode(json_updated),
{:ok, %Project{} = _project_updated} <- Projects.update_project_file(project, content_updated, current_user, now),
do: conn |> render("show.json", source: source, ctx: ctx)
end

def delete(conn, %{"organization_id" => _organization_id, "project_id" => project_id, "source_id" => source_id} = params) do
Expand All @@ -53,4 +80,20 @@ defmodule AzimuttWeb.Api.SourceController do
{:ok, %Project{} = _project_updated} <- Projects.update_project_file(project, content_updated, current_user, now),
do: conn |> render("show.json", source: source, ctx: ctx)
end

defp update_source(source, params) do
source
|> Map.put("tables", params["tables"])
|> Map.put("relations", params["relations"])
|> Mapx.put_no_nil("types", params["types"])
end

defp validate_json_schema(schema, json) do
# TODO: add the string uuid format validation
ExJsonSchema.Validator.validate(schema, json)
|> Result.map_both(
fn errors -> %{errors: errors |> Enum.map(fn {error, path} -> %{path: path, error: error} end)} end,
fn _ -> json end
)
end
end
2 changes: 1 addition & 1 deletion backend/lib/azimutt_web/controllers/user_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ defmodule AzimuttWeb.UserAuth do

user = user |> Azimutt.Repo.preload(:profile) |> Azimutt.Repo.preload(organizations: [:clever_cloud_resource, :heroku_resource, :projects])

assign(conn, :current_user, user)
conn |> assign(:current_user, user)
end

defp ensure_user_token(conn) do
Expand Down
4 changes: 1 addition & 3 deletions backend/lib/azimutt_web/utils/ctx_params.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
defmodule AzimuttWeb.Utils.CtxParams do
@moduledoc """
Parse generic optional params and make them easily accessible.
"""
@moduledoc "Parse generic optional params and make them easily accessible."
use TypedStruct
alias AzimuttWeb.Utils.CtxParams

Expand Down
132 changes: 132 additions & 0 deletions backend/lib/azimutt_web/utils/project_schema.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
defmodule AzimuttWeb.Utils.ProjectSchema do
@moduledoc "JSON Schema definition for project"

# MUST stay in sync with frontend/ts-src/types/project.ts

@type_schema %{
"type" => "object",
"additionalProperties" => false,
"required" => ["schema", "name", "value"],
"properties" => %{
"schema" => %{"type" => "string"},
"name" => %{"type" => "string"},
"value" => %{
"anyOf" => [
%{"type" => "object", "additionalProperties" => false, "required" => ["enum"], "properties" => %{"enum" => %{"type" => "array", "items" => %{"type" => "string"}}}},
%{"type" => "object", "additionalProperties" => false, "required" => ["definition"], "properties" => %{"definition" => %{"type" => "string"}}}
]
}
}
}

@column_ref %{
"type" => "object",
"additionalProperties" => false,
"required" => ["table", "column"],
"properties" => %{
"table" => %{"type" => "string"},
"column" => %{"type" => "string"}
}
}

@relation %{
"type" => "object",
"additionalProperties" => false,
"required" => ["name", "src", "ref"],
"properties" => %{
"name" => %{"type" => "string"},
"src" => @column_ref,
"ref" => @column_ref
}
}

@comment %{
"type" => "object",
"additionalProperties" => false,
"required" => ["text"],
"properties" => %{
"text" => %{"type" => "string"}
}
}

@check %{
"type" => "object",
"additionalProperties" => false,
"required" => ["name", "columns"],
"properties" => %{
"name" => %{"type" => "string"},
"columns" => %{"type" => "array", "items" => %{"type" => "string"}},
"predicate" => %{"type" => "string"}
}
}

@index %{
"type" => "object",
"additionalProperties" => false,
"required" => ["name", "columns"],
"properties" => %{
"name" => %{"type" => "string"},
"columns" => %{"type" => "array", "items" => %{"type" => "string"}},
"definition" => %{"type" => "string"}
}
}

@unique %{
"type" => "object",
"additionalProperties" => false,
"required" => ["name", "columns"],
"properties" => %{
"name" => %{"type" => "string"},
"columns" => %{"type" => "array", "items" => %{"type" => "string"}},
"definition" => %{"type" => "string"}
}
}

@primary_key %{
"type" => "object",
"additionalProperties" => false,
"required" => ["columns"],
"properties" => %{
"name" => %{"type" => "string"},
"columns" => %{"type" => "array", "items" => %{"type" => "string"}}
}
}

@column %{
"type" => "object",
"additionalProperties" => false,
"required" => ["name", "type"],
"properties" => %{
"name" => %{"type" => "string"},
"type" => %{"type" => "string"},
"nullable" => %{"type" => "boolean"},
"default" => %{"type" => "string"},
"comment" => @comment,
"values" => %{"type" => "array", "items" => %{"type" => "string"}},
# MUST include the column inside the `definitions` attribute in the global schema
"columns" => %{"type" => "array", "items" => %{"$ref" => "#/definitions/column"}}
}
}

@table %{
"type" => "object",
"additionalProperties" => false,
"required" => ["schema", "table", "columns"],
"properties" => %{
"schema" => %{"type" => "string"},
"table" => %{"type" => "string"},
"view" => %{"type" => "boolean"},
"columns" => %{"type" => "array", "items" => @column},
"primaryKey" => @primary_key,
"uniques" => %{"type" => "array", "items" => @unique},
"indexes" => %{"type" => "array", "items" => @index},
"checks" => %{"type" => "array", "items" => @check},
"comment" => @comment
}
}

def table, do: @table
def column, do: @column
def relation, do: @relation
def type, do: @type_schema
end
Loading

0 comments on commit 1d0effd

Please sign in to comment.