Skip to content

Commit fb9c180

Browse files
feat: add Services CRUD API endpoints
REST API for managing services programmatically via API keys, following the same patterns as BundleController. Includes endpoints for CRUD, KDL preview, reorder, and bundle generation from services.
1 parent 65ef94c commit fb9c180

3 files changed

Lines changed: 686 additions & 0 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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

lib/sentinel_cp_web/router.ex

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ defmodule SentinelCpWeb.Router do
5555
plug SentinelCpWeb.Plugs.RequireScope, scope: "rollouts:write"
5656
end
5757

58+
pipeline :require_services_read do
59+
plug SentinelCpWeb.Plugs.RequireScope, scope: "services:read"
60+
end
61+
62+
pipeline :require_services_write do
63+
plug SentinelCpWeb.Plugs.RequireScope, scope: "services:write"
64+
end
65+
5866
pipeline :require_api_keys_admin do
5967
plug SentinelCpWeb.Plugs.RequireScope, scope: "api_keys:admin"
6068
end
@@ -279,6 +287,30 @@ defmodule SentinelCpWeb.Router do
279287
end
280288
end
281289

290+
# Control plane API — Services (read)
291+
scope "/api/v1", SentinelCpWeb.Api do
292+
pipe_through [:api, :api_auth, :require_services_read]
293+
294+
scope "/projects/:project_slug" do
295+
get "/services/preview-kdl", ServiceController, :preview_kdl
296+
get "/services", ServiceController, :index
297+
get "/services/:id", ServiceController, :show
298+
end
299+
end
300+
301+
# Control plane API — Services (write)
302+
scope "/api/v1", SentinelCpWeb.Api do
303+
pipe_through [:api, :api_auth, :require_services_write]
304+
305+
scope "/projects/:project_slug" do
306+
post "/services/generate-bundle", ServiceController, :generate_bundle
307+
put "/services/reorder", ServiceController, :reorder
308+
post "/services", ServiceController, :create
309+
put "/services/:id", ServiceController, :update
310+
delete "/services/:id", ServiceController, :delete
311+
end
312+
end
313+
282314
# Control plane API — API Key management
283315
scope "/api/v1", SentinelCpWeb.Api do
284316
pipe_through [:api, :api_auth, :require_api_keys_admin]

0 commit comments

Comments
 (0)