|
| 1 | +defmodule SentinelCpWeb.Api.ServiceController do |
| 2 | + @moduledoc """ |
| 3 | + API controller for service management. |
| 4 | + """ |
| 5 | + use SentinelCpWeb, :controller |
| 6 | + |
| 7 | + alias SentinelCp.{Services, Projects, Audit} |
| 8 | + alias SentinelCp.Services.BundleIntegration |
| 9 | + |
| 10 | + @doc """ |
| 11 | + GET /api/v1/projects/:project_slug/services |
| 12 | + Lists services for a project. |
| 13 | + """ |
| 14 | + def index(conn, %{"project_slug" => project_slug} = params) do |
| 15 | + with {:ok, project} <- get_project(project_slug) do |
| 16 | + opts = |
| 17 | + if params["enabled"] do |
| 18 | + [enabled: params["enabled"] == "true"] |
| 19 | + else |
| 20 | + [] |
| 21 | + end |
| 22 | + |
| 23 | + services = Services.list_services(project.id, opts) |
| 24 | + |
| 25 | + conn |
| 26 | + |> put_status(:ok) |
| 27 | + |> json(%{ |
| 28 | + services: Enum.map(services, &service_to_json/1), |
| 29 | + total: length(services) |
| 30 | + }) |
| 31 | + else |
| 32 | + {:error, :project_not_found} -> |
| 33 | + conn |> put_status(:not_found) |> json(%{error: "Project not found"}) |
| 34 | + end |
| 35 | + end |
| 36 | + |
| 37 | + @doc """ |
| 38 | + GET /api/v1/projects/:project_slug/services/:id |
| 39 | + Shows service details. |
| 40 | + """ |
| 41 | + def show(conn, %{"project_slug" => project_slug, "id" => id}) do |
| 42 | + with {:ok, project} <- get_project(project_slug), |
| 43 | + {:ok, service} <- get_service(id, project.id) do |
| 44 | + conn |
| 45 | + |> put_status(:ok) |
| 46 | + |> json(%{service: service_to_json(service)}) |
| 47 | + else |
| 48 | + {:error, :project_not_found} -> |
| 49 | + conn |> put_status(:not_found) |> json(%{error: "Project not found"}) |
| 50 | + |
| 51 | + {:error, :service_not_found} -> |
| 52 | + conn |> put_status(:not_found) |> json(%{error: "Service not found"}) |
| 53 | + end |
| 54 | + end |
| 55 | + |
| 56 | + @doc """ |
| 57 | + POST /api/v1/projects/:project_slug/services |
| 58 | + Creates a new service. |
| 59 | + """ |
| 60 | + def create(conn, %{"project_slug" => project_slug} = params) do |
| 61 | + with {:ok, project} <- get_project(project_slug), |
| 62 | + attrs <- Map.put(params, "project_id", project.id), |
| 63 | + {:ok, service} <- Services.create_service(attrs) do |
| 64 | + api_key = conn.assigns.current_api_key |
| 65 | + |
| 66 | + Audit.log_api_key_action(api_key, "service.created", "service", service.id, |
| 67 | + project_id: project.id, |
| 68 | + changes: %{name: service.name, route_path: service.route_path} |
| 69 | + ) |
| 70 | + |
| 71 | + conn |
| 72 | + |> put_status(:created) |
| 73 | + |> json(%{service: service_to_json(service)}) |
| 74 | + else |
| 75 | + {:error, :project_not_found} -> |
| 76 | + conn |> put_status(:not_found) |> json(%{error: "Project not found"}) |
| 77 | + |
| 78 | + {:error, %Ecto.Changeset{} = changeset} -> |
| 79 | + conn |> put_status(:unprocessable_entity) |> json(%{error: format_errors(changeset)}) |
| 80 | + end |
| 81 | + end |
| 82 | + |
| 83 | + @doc """ |
| 84 | + PUT /api/v1/projects/:project_slug/services/:id |
| 85 | + Updates an existing service. |
| 86 | + """ |
| 87 | + def update(conn, %{"project_slug" => project_slug, "id" => id} = params) do |
| 88 | + with {:ok, project} <- get_project(project_slug), |
| 89 | + {:ok, service} <- get_service(id, project.id), |
| 90 | + {:ok, updated} <- Services.update_service(service, params) do |
| 91 | + api_key = conn.assigns.current_api_key |
| 92 | + |
| 93 | + Audit.log_api_key_action(api_key, "service.updated", "service", service.id, |
| 94 | + project_id: project.id, |
| 95 | + changes: %{name: updated.name} |
| 96 | + ) |
| 97 | + |
| 98 | + conn |
| 99 | + |> put_status(:ok) |
| 100 | + |> json(%{service: service_to_json(updated)}) |
| 101 | + else |
| 102 | + {:error, :project_not_found} -> |
| 103 | + conn |> put_status(:not_found) |> json(%{error: "Project not found"}) |
| 104 | + |
| 105 | + {:error, :service_not_found} -> |
| 106 | + conn |> put_status(:not_found) |> json(%{error: "Service not found"}) |
| 107 | + |
| 108 | + {:error, %Ecto.Changeset{} = changeset} -> |
| 109 | + conn |> put_status(:unprocessable_entity) |> json(%{error: format_errors(changeset)}) |
| 110 | + end |
| 111 | + end |
| 112 | + |
| 113 | + @doc """ |
| 114 | + DELETE /api/v1/projects/:project_slug/services/:id |
| 115 | + Deletes a service. |
| 116 | + """ |
| 117 | + def delete(conn, %{"project_slug" => project_slug, "id" => id}) do |
| 118 | + with {:ok, project} <- get_project(project_slug), |
| 119 | + {:ok, service} <- get_service(id, project.id), |
| 120 | + {:ok, _deleted} <- Services.delete_service(service) do |
| 121 | + api_key = conn.assigns.current_api_key |
| 122 | + |
| 123 | + Audit.log_api_key_action(api_key, "service.deleted", "service", service.id, |
| 124 | + project_id: project.id, |
| 125 | + changes: %{name: service.name} |
| 126 | + ) |
| 127 | + |
| 128 | + send_resp(conn, :no_content, "") |
| 129 | + else |
| 130 | + {:error, :project_not_found} -> |
| 131 | + conn |> put_status(:not_found) |> json(%{error: "Project not found"}) |
| 132 | + |
| 133 | + {:error, :service_not_found} -> |
| 134 | + conn |> put_status(:not_found) |> json(%{error: "Service not found"}) |
| 135 | + end |
| 136 | + end |
| 137 | + |
| 138 | + @doc """ |
| 139 | + GET /api/v1/projects/:project_slug/services/preview-kdl |
| 140 | + Previews the generated KDL configuration. |
| 141 | + """ |
| 142 | + def preview_kdl(conn, %{"project_slug" => project_slug}) do |
| 143 | + with {:ok, project} <- get_project(project_slug), |
| 144 | + {:ok, kdl} <- BundleIntegration.preview_kdl(project.id) do |
| 145 | + conn |
| 146 | + |> put_status(:ok) |
| 147 | + |> json(%{kdl: kdl}) |
| 148 | + else |
| 149 | + {:error, :project_not_found} -> |
| 150 | + conn |> put_status(:not_found) |> json(%{error: "Project not found"}) |
| 151 | + |
| 152 | + {:error, :no_services} -> |
| 153 | + conn |
| 154 | + |> put_status(:unprocessable_entity) |
| 155 | + |> json(%{error: "No enabled services found for this project"}) |
| 156 | + end |
| 157 | + end |
| 158 | + |
| 159 | + @doc """ |
| 160 | + PUT /api/v1/projects/:project_slug/services/reorder |
| 161 | + Reorders services by position. |
| 162 | + """ |
| 163 | + def reorder(conn, %{"project_slug" => project_slug, "service_ids" => service_ids}) |
| 164 | + when is_list(service_ids) do |
| 165 | + with {:ok, project} <- get_project(project_slug) do |
| 166 | + id_position_pairs = |
| 167 | + service_ids |
| 168 | + |> Enum.with_index() |
| 169 | + |> Enum.map(fn {id, idx} -> {id, idx} end) |
| 170 | + |
| 171 | + case Services.reorder_services(project.id, id_position_pairs) do |
| 172 | + {:ok, _} -> |
| 173 | + api_key = conn.assigns.current_api_key |
| 174 | + |
| 175 | + Audit.log_api_key_action(api_key, "services.reordered", "project", project.id, |
| 176 | + project_id: project.id, |
| 177 | + changes: %{service_ids: service_ids} |
| 178 | + ) |
| 179 | + |
| 180 | + conn |
| 181 | + |> put_status(:ok) |
| 182 | + |> json(%{ok: true}) |
| 183 | + |
| 184 | + {:error, reason} -> |
| 185 | + conn |
| 186 | + |> put_status(:unprocessable_entity) |
| 187 | + |> json(%{error: inspect(reason)}) |
| 188 | + end |
| 189 | + else |
| 190 | + {:error, :project_not_found} -> |
| 191 | + conn |> put_status(:not_found) |> json(%{error: "Project not found"}) |
| 192 | + end |
| 193 | + end |
| 194 | + |
| 195 | + def reorder(conn, %{"project_slug" => _project_slug}) do |
| 196 | + conn |
| 197 | + |> put_status(:unprocessable_entity) |
| 198 | + |> json(%{error: "service_ids must be a list"}) |
| 199 | + end |
| 200 | + |
| 201 | + @doc """ |
| 202 | + POST /api/v1/projects/:project_slug/services/generate-bundle |
| 203 | + Generates a bundle from the project's service definitions. |
| 204 | + """ |
| 205 | + def generate_bundle(conn, %{"project_slug" => project_slug} = params) do |
| 206 | + with {:ok, project} <- get_project(project_slug), |
| 207 | + version <- params["version"], |
| 208 | + :ok <- validate_version(version), |
| 209 | + created_by_id <- |
| 210 | + conn.assigns[:current_api_key] && conn.assigns.current_api_key.user_id, |
| 211 | + {:ok, bundle} <- |
| 212 | + BundleIntegration.create_bundle_from_services(project.id, version, |
| 213 | + created_by_id: created_by_id |
| 214 | + ) do |
| 215 | + api_key = conn.assigns.current_api_key |
| 216 | + |
| 217 | + Audit.log_api_key_action( |
| 218 | + api_key, |
| 219 | + "bundle.created_from_services", |
| 220 | + "bundle", |
| 221 | + bundle.id, |
| 222 | + project_id: project.id, |
| 223 | + changes: %{version: version} |
| 224 | + ) |
| 225 | + |
| 226 | + conn |
| 227 | + |> put_status(:created) |
| 228 | + |> json(%{ |
| 229 | + bundle: %{ |
| 230 | + id: bundle.id, |
| 231 | + version: bundle.version, |
| 232 | + status: bundle.status, |
| 233 | + project_id: project.id |
| 234 | + } |
| 235 | + }) |
| 236 | + else |
| 237 | + {:error, :project_not_found} -> |
| 238 | + conn |> put_status(:not_found) |> json(%{error: "Project not found"}) |
| 239 | + |
| 240 | + {:error, :no_services} -> |
| 241 | + conn |
| 242 | + |> put_status(:unprocessable_entity) |
| 243 | + |> json(%{error: "No enabled services found for this project"}) |
| 244 | + |
| 245 | + {:error, :version_required} -> |
| 246 | + conn |
| 247 | + |> put_status(:unprocessable_entity) |
| 248 | + |> json(%{error: "version is required"}) |
| 249 | + |
| 250 | + {:error, %Ecto.Changeset{} = changeset} -> |
| 251 | + conn |> put_status(:unprocessable_entity) |> json(%{error: format_errors(changeset)}) |
| 252 | + end |
| 253 | + end |
| 254 | + |
| 255 | + # Helpers |
| 256 | + |
| 257 | + defp get_project(slug) do |
| 258 | + case Projects.get_project_by_slug(slug) do |
| 259 | + nil -> {:error, :project_not_found} |
| 260 | + project -> {:ok, project} |
| 261 | + end |
| 262 | + end |
| 263 | + |
| 264 | + defp get_service(id, project_id) do |
| 265 | + case Services.get_service(id) do |
| 266 | + nil -> {:error, :service_not_found} |
| 267 | + %{project_id: ^project_id} = service -> {:ok, service} |
| 268 | + _ -> {:error, :service_not_found} |
| 269 | + end |
| 270 | + end |
| 271 | + |
| 272 | + defp validate_version(nil), do: {:error, :version_required} |
| 273 | + defp validate_version(""), do: {:error, :version_required} |
| 274 | + defp validate_version(_version), do: :ok |
| 275 | + |
| 276 | + defp service_to_json(service) do |
| 277 | + %{ |
| 278 | + id: service.id, |
| 279 | + name: service.name, |
| 280 | + slug: service.slug, |
| 281 | + description: service.description, |
| 282 | + enabled: service.enabled, |
| 283 | + position: service.position, |
| 284 | + route_path: service.route_path, |
| 285 | + upstream_url: service.upstream_url, |
| 286 | + respond_status: service.respond_status, |
| 287 | + respond_body: service.respond_body, |
| 288 | + timeout_seconds: service.timeout_seconds, |
| 289 | + retry: service.retry, |
| 290 | + cache: service.cache, |
| 291 | + rate_limit: service.rate_limit, |
| 292 | + health_check: service.health_check, |
| 293 | + headers: service.headers, |
| 294 | + project_id: service.project_id, |
| 295 | + inserted_at: service.inserted_at, |
| 296 | + updated_at: service.updated_at |
| 297 | + } |
| 298 | + end |
| 299 | + |
| 300 | + defp format_errors(changeset) do |
| 301 | + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> |
| 302 | + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> |
| 303 | + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() |
| 304 | + end) |
| 305 | + end) |
| 306 | + end |
| 307 | +end |
0 commit comments