Skip to content

Commit 9179590

Browse files
feat: add grpc, websocket, graphql, and streaming service types
Extend the service type system with four new types following the same pattern as inference. Each type has its own config map field with validation (required when active, empty when inactive). Includes built-in templates, KDL generation, config export/import, and tests.
1 parent d4c74d2 commit 9179590

10 files changed

Lines changed: 724 additions & 29 deletions

File tree

lib/sentinel_cp/config_export.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,10 @@ defmodule SentinelCp.ConfigExport do
214214
|> maybe_add("compression", s.compression, %{})
215215
|> maybe_add("security", s.security, %{})
216216
|> maybe_add("inference", s.inference, %{})
217+
|> maybe_add("grpc", s.grpc, %{})
218+
|> maybe_add("websocket", s.websocket, %{})
219+
|> maybe_add("graphql", s.graphql, %{})
220+
|> maybe_add("streaming", s.streaming, %{})
217221
end)
218222
end
219223

@@ -295,7 +299,11 @@ defmodule SentinelCp.ConfigExport do
295299
enabled: Map.get(svc_data, "enabled", true),
296300
position: Map.get(svc_data, "position", 0),
297301
service_type: Map.get(svc_data, "service_type", "standard"),
298-
inference: Map.get(svc_data, "inference", %{})
302+
inference: Map.get(svc_data, "inference", %{}),
303+
grpc: Map.get(svc_data, "grpc", %{}),
304+
websocket: Map.get(svc_data, "websocket", %{}),
305+
graphql: Map.get(svc_data, "graphql", %{}),
306+
streaming: Map.get(svc_data, "streaming", %{})
299307
}
300308

301309
case SentinelCp.Services.create_service(attrs) do

lib/sentinel_cp/services/built_in_templates.ex

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,78 @@ defmodule SentinelCp.Services.BuiltInTemplates do
123123
},
124124
is_builtin: true,
125125
version: 1
126+
},
127+
%{
128+
name: "gRPC Gateway",
129+
category: "grpc",
130+
description: "gRPC gateway with reflection and health checking.",
131+
template_data: %{
132+
"upstream_url" => "http://grpc-backend:9090",
133+
"route_path" => "/grpc/*",
134+
"service_type" => "grpc",
135+
"grpc" => %{
136+
"max_message_size" => 4_194_304,
137+
"reflection" => "true",
138+
"health_check_service" => "grpc.health.v1.Health"
139+
}
140+
},
141+
is_builtin: true,
142+
version: 1
143+
},
144+
%{
145+
name: "WebSocket Gateway",
146+
category: "websocket",
147+
description: "WebSocket gateway with connection management and ping/pong.",
148+
template_data: %{
149+
"upstream_url" => "http://ws-backend:8080",
150+
"route_path" => "/ws/*",
151+
"service_type" => "websocket",
152+
"timeout_seconds" => 300,
153+
"websocket" => %{
154+
"ping_interval" => 30,
155+
"max_message_size" => 65_536,
156+
"max_connections" => 10_000
157+
}
158+
},
159+
is_builtin: true,
160+
version: 1
161+
},
162+
%{
163+
name: "GraphQL Gateway",
164+
category: "graphql",
165+
description: "GraphQL gateway with depth limiting, complexity analysis, and introspection control.",
166+
template_data: %{
167+
"upstream_url" => "http://graphql-backend:4000",
168+
"route_path" => "/graphql",
169+
"service_type" => "graphql",
170+
"graphql" => %{
171+
"max_depth" => 10,
172+
"max_complexity" => 1000,
173+
"introspection" => "true",
174+
"persisted_queries" => "false"
175+
}
176+
},
177+
is_builtin: true,
178+
version: 1
179+
},
180+
%{
181+
name: "SSE Streaming Service",
182+
category: "streaming",
183+
description: "Server-Sent Events streaming service with keepalive and buffering.",
184+
template_data: %{
185+
"upstream_url" => "http://streaming-backend:8080",
186+
"route_path" => "/events/*",
187+
"service_type" => "streaming",
188+
"timeout_seconds" => 600,
189+
"streaming" => %{
190+
"format" => "sse",
191+
"keepalive_interval" => 15,
192+
"max_connection_duration" => 3600,
193+
"buffer_size" => 1024
194+
}
195+
},
196+
is_builtin: true,
197+
version: 1
126198
}
127199
]
128200

lib/sentinel_cp/services/kdl_generator.ex

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ defmodule SentinelCp.Services.KdlGenerator do
6262
defp resolve_service_configs(services, project_id, environment) do
6363
config_fields = [:headers, :cors, :cache, :retry, :rate_limit, :health_check,
6464
:access_control, :compression, :security, :request_transform,
65-
:response_transform, :path_rewrite, :inference]
65+
:response_transform, :path_rewrite, :inference,
66+
:grpc, :websocket, :graphql, :streaming]
6667

6768
Enum.reduce_while(services, {:ok, []}, fn service, {:ok, acc} ->
6869
case resolve_service_config_maps(service, config_fields, project_id, environment) do
@@ -267,6 +268,10 @@ defmodule SentinelCp.Services.KdlGenerator do
267268
lines = lines ++ build_response_transform_block(service.response_transform)
268269
lines = lines ++ build_traffic_split_block(service.traffic_split, group_map)
269270
lines = lines ++ build_inference_block(service.inference)
271+
lines = lines ++ build_grpc_block(service.grpc)
272+
lines = lines ++ build_websocket_block(service.websocket)
273+
lines = lines ++ build_graphql_block(service.graphql)
274+
lines = lines ++ build_streaming_block(service.streaming)
270275

271276
# Append middleware chain blocks (after inline fields)
272277
lines = lines ++ build_middleware_chain(middleware_chain)
@@ -510,6 +515,18 @@ defmodule SentinelCp.Services.KdlGenerator do
510515
lines ++ [" }"]
511516
end
512517

518+
defp build_grpc_block(g) when g == %{} or g == nil, do: []
519+
defp build_grpc_block(g), do: build_nested_map_block(g, "grpc", " ")
520+
521+
defp build_websocket_block(ws) when ws == %{} or ws == nil, do: []
522+
defp build_websocket_block(ws), do: build_nested_map_block(ws, "websocket", " ")
523+
524+
defp build_graphql_block(gql) when gql == %{} or gql == nil, do: []
525+
defp build_graphql_block(gql), do: build_nested_map_block(gql, "graphql", " ")
526+
527+
defp build_streaming_block(s) when s == %{} or s == nil, do: []
528+
defp build_streaming_block(s), do: build_nested_map_block(s, "streaming", " ")
529+
513530
defp build_inference_sub_block(nil, _name), do: []
514531
defp build_inference_sub_block(map, _name) when map == %{}, do: []
515532

lib/sentinel_cp/services/service.ex

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ defmodule SentinelCp.Services.Service do
3636
field :traffic_split, :map, default: %{}
3737
field :service_type, :string, default: "standard"
3838
field :inference, :map, default: %{}
39+
field :grpc, :map, default: %{}
40+
field :websocket, :map, default: %{}
41+
field :graphql, :map, default: %{}
42+
field :streaming, :map, default: %{}
3943
field :redirect_url, :string
4044

4145
belongs_to :project, SentinelCp.Projects.Project
@@ -78,6 +82,10 @@ defmodule SentinelCp.Services.Service do
7882
:traffic_split,
7983
:service_type,
8084
:inference,
85+
:grpc,
86+
:websocket,
87+
:graphql,
88+
:streaming,
8189
:redirect_url,
8290
:upstream_group_id,
8391
:certificate_id,
@@ -90,8 +98,8 @@ defmodule SentinelCp.Services.Service do
9098
|> validate_length(:name, min: 1, max: 100)
9199
|> validate_route_path()
92100
|> validate_route_type()
93-
|> validate_inclusion(:service_type, ~w(standard inference))
94-
|> validate_inference_config()
101+
|> validate_inclusion(:service_type, ~w(standard inference grpc websocket graphql streaming))
102+
|> validate_service_type_config()
95103
|> generate_slug()
96104
|> validate_slug()
97105
|> unique_constraint([:project_id, :slug], error_key: :slug)
@@ -125,6 +133,10 @@ defmodule SentinelCp.Services.Service do
125133
:traffic_split,
126134
:service_type,
127135
:inference,
136+
:grpc,
137+
:websocket,
138+
:graphql,
139+
:streaming,
128140
:redirect_url,
129141
:upstream_group_id,
130142
:certificate_id,
@@ -136,36 +148,68 @@ defmodule SentinelCp.Services.Service do
136148
|> validate_length(:name, min: 1, max: 100)
137149
|> validate_route_path()
138150
|> validate_route_type()
139-
|> validate_inclusion(:service_type, ~w(standard inference))
140-
|> validate_inference_config()
151+
|> validate_inclusion(:service_type, ~w(standard inference grpc websocket graphql streaming))
152+
|> validate_service_type_config()
141153
end
142154

143-
defp validate_inference_config(changeset) do
155+
@type_config_fields %{
156+
"inference" => :inference,
157+
"grpc" => :grpc,
158+
"websocket" => :websocket,
159+
"graphql" => :graphql,
160+
"streaming" => :streaming
161+
}
162+
163+
defp validate_service_type_config(changeset) do
144164
service_type = get_field(changeset, :service_type)
145-
inference = get_field(changeset, :inference)
146165

147-
case service_type do
148-
"inference" ->
149-
cond do
150-
not is_map(inference) or inference == %{} ->
151-
add_error(changeset, :inference, "is required when service_type is inference")
166+
# Validate the active type's config is present
167+
changeset = validate_active_type_config(changeset, service_type)
152168

153-
Map.get(inference, "provider") not in ~w(openai anthropic generic) ->
154-
add_error(changeset, :inference, "must include a valid provider (openai, anthropic, generic)")
169+
# Validate all inactive type configs are empty
170+
Enum.reduce(@type_config_fields, changeset, fn {type, field}, cs ->
171+
if type != service_type do
172+
value = get_field(cs, field)
155173

156-
true ->
157-
changeset
158-
end
159-
160-
_ ->
161-
if is_map(inference) and inference != %{} do
162-
add_error(changeset, :inference, "must be empty for standard services")
174+
if is_map(value) and value != %{} do
175+
add_error(cs, field, "must be empty when service_type is not #{type}")
163176
else
164-
changeset
177+
cs
165178
end
179+
else
180+
cs
181+
end
182+
end)
183+
end
184+
185+
defp validate_active_type_config(changeset, "inference") do
186+
inference = get_field(changeset, :inference)
187+
188+
cond do
189+
not is_map(inference) or inference == %{} ->
190+
add_error(changeset, :inference, "is required when service_type is inference")
191+
192+
Map.get(inference, "provider") not in ~w(openai anthropic generic) ->
193+
add_error(changeset, :inference, "must include a valid provider (openai, anthropic, generic)")
194+
195+
true ->
196+
changeset
166197
end
167198
end
168199

200+
defp validate_active_type_config(changeset, type) when type in ~w(grpc websocket graphql streaming) do
201+
field = Map.fetch!(@type_config_fields, type)
202+
value = get_field(changeset, field)
203+
204+
if not is_map(value) or value == %{} do
205+
add_error(changeset, field, "is required when service_type is #{type}")
206+
else
207+
changeset
208+
end
209+
end
210+
211+
defp validate_active_type_config(changeset, _standard), do: changeset
212+
169213
defp validate_route_path(changeset) do
170214
validate_format(changeset, :route_path, ~r/^\//, message: "must start with /")
171215
end

lib/sentinel_cp/services/service_template.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule SentinelCp.Services.ServiceTemplate do
1111
@primary_key {:id, :binary_id, autogenerate: true}
1212
@foreign_key_type :binary_id
1313

14-
@categories ~w(api web websocket static auth utility inference)
14+
@categories ~w(api web websocket static auth utility inference grpc graphql streaming)
1515

1616
schema "service_templates" do
1717
field :name, :string
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
defmodule SentinelCp.Repo.Migrations.AddServiceTypeConfigs do
2+
use Ecto.Migration
3+
4+
def change do
5+
alter table(:services) do
6+
add :grpc, :map, default: %{}
7+
add :websocket, :map, default: %{}
8+
add :graphql, :map, default: %{}
9+
add :streaming, :map, default: %{}
10+
end
11+
end
12+
end

test/sentinel_cp/config_export_test.exs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,36 @@ defmodule SentinelCp.ConfigExportTest do
198198
end
199199
end
200200

201+
describe "typed service export/import" do
202+
test "exports and imports grpc service config", %{project: project} do
203+
{:ok, _} =
204+
SentinelCp.Services.create_service(%{
205+
project_id: project.id,
206+
name: "grpc-gateway",
207+
route_path: "/grpc/*",
208+
upstream_url: "http://grpc:9090",
209+
service_type: "grpc",
210+
grpc: %{"max_message_size" => 4_194_304, "reflection" => "true"}
211+
})
212+
213+
{:ok, config} = ConfigExport.export(project.id)
214+
215+
svc = Enum.find(config["services"], &(&1["name"] == "grpc-gateway"))
216+
assert svc["service_type"] == "grpc"
217+
assert svc["grpc"]["max_message_size"] == 4_194_304
218+
219+
# Import into a new project
220+
project2 = project_fixture()
221+
{:ok, summary} = ConfigExport.import_config(project2.id, config)
222+
assert summary.created >= 1
223+
224+
services = SentinelCp.Services.list_services(project2.id)
225+
imported = Enum.find(services, &(&1.name == "grpc-gateway"))
226+
assert imported.service_type == "grpc"
227+
assert imported.grpc["max_message_size"] == 4_194_304
228+
end
229+
end
230+
201231
# ─── 18.4 GraphQL Schema ────────────────────────────────────────
202232

203233
describe "GraphQL schema" do

0 commit comments

Comments
 (0)