Skip to content

Commit 7df27b0

Browse files
Add API resource probe, OpenAPI discovery, and schema-aware step editor
1 parent ffef5dc commit 7df27b0

7 files changed

Lines changed: 517 additions & 11 deletions

File tree

lib/alex_claw/resources.ex

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,35 @@ defmodule AlexClaw.Resources do
2929

3030
@spec create_resource(map()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
3131
def create_resource(attrs) do
32-
%Resource{}
33-
|> Resource.changeset(attrs)
34-
|> Repo.insert()
32+
result =
33+
%Resource{}
34+
|> Resource.changeset(attrs)
35+
|> Repo.insert()
36+
37+
with {:ok, resource} <- result, do: maybe_trigger_discovery(resource)
38+
result
3539
end
3640

37-
@spec update_resource(Resource.t(), map()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
38-
def update_resource(%Resource{} = resource, attrs) do
39-
resource
40-
|> Resource.changeset(attrs)
41-
|> Repo.update()
41+
@spec update_resource(Resource.t(), map(), keyword()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
42+
def update_resource(%Resource{} = resource, attrs, opts \\ []) do
43+
result =
44+
resource
45+
|> Resource.changeset(attrs)
46+
|> Repo.update()
47+
48+
unless opts[:skip_discovery] do
49+
with {:ok, updated} <- result, do: maybe_trigger_discovery(updated)
50+
end
51+
52+
result
4253
end
4354

55+
defp maybe_trigger_discovery(%Resource{type: "api"} = resource) do
56+
AlexClaw.Resources.ApiDiscovery.run_async(resource)
57+
end
58+
59+
defp maybe_trigger_discovery(_resource), do: :ok
60+
4461
@spec delete_resource(Resource.t()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
4562
def delete_resource(%Resource{} = resource) do
4663
Repo.delete(resource)
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
defmodule AlexClaw.Resources.ApiDiscovery do
2+
@moduledoc """
3+
Async probe and OpenAPI spec discovery for API resources.
4+
5+
When an API resource is created or updated, this module runs a background task
6+
that probes the URL and attempts to discover an OpenAPI/Swagger spec. Results
7+
are stored in `metadata["discovery"]` on the resource.
8+
"""
9+
10+
require Logger
11+
12+
alias AlexClaw.Resources
13+
alias AlexClaw.Resources.Resource
14+
15+
@pubsub AlexClaw.PubSub
16+
@discovery_topic "resources:discovery"
17+
18+
@spec_paths ~w(
19+
/openapi.json
20+
/swagger.json
21+
/api-docs
22+
/v3/api-docs
23+
/swagger/v1/swagger.json
24+
)
25+
26+
@probe_timeout 10_000
27+
@spec_max_bytes 2_097_152
28+
@max_endpoints 100
29+
30+
@spec topic() :: String.t()
31+
def topic, do: @discovery_topic
32+
33+
@spec run_async(Resource.t()) :: {:ok, pid()} | :ignore
34+
def run_async(%Resource{type: "api", url: url} = resource) when is_binary(url) and url != "" do
35+
Task.Supervisor.start_child(AlexClaw.TaskSupervisor, fn ->
36+
discover(resource)
37+
end)
38+
end
39+
40+
def run_async(_resource), do: :ignore
41+
42+
defp discover(resource) do
43+
mark_status(resource, "running")
44+
45+
url = String.trim(resource.url)
46+
probe_result = probe(url)
47+
base_url = derive_base_url(url)
48+
openapi_result = discover_openapi(base_url, url)
49+
50+
discovery = %{
51+
"status" => "completed",
52+
"probed_at" => DateTime.to_iso8601(DateTime.utc_now()),
53+
"base_url" => base_url,
54+
"probe" => probe_result,
55+
"openapi" => openapi_result,
56+
"error" => nil
57+
}
58+
59+
save_discovery(resource, discovery)
60+
broadcast(resource.id, :completed)
61+
62+
Logger.info("[ApiDiscovery] Completed for #{resource.name} (#{resource.url})",
63+
resource_id: resource.id
64+
)
65+
rescue
66+
e ->
67+
error_msg = Exception.message(e)
68+
69+
save_discovery(resource, %{
70+
"status" => "failed",
71+
"probed_at" => DateTime.to_iso8601(DateTime.utc_now()),
72+
"error" => error_msg
73+
})
74+
75+
broadcast(resource.id, {:failed, error_msg})
76+
77+
Logger.warning("[ApiDiscovery] Failed for #{resource.name}: #{error_msg}",
78+
resource_id: resource.id
79+
)
80+
end
81+
82+
@spec probe(String.t()) :: map()
83+
defp probe(url) do
84+
case Req.head(url, receive_timeout: @probe_timeout, retry: false, redirect: true, max_redirects: 3) do
85+
{:ok, %{status: status, headers: headers}} ->
86+
%{
87+
"http_status" => status,
88+
"content_type" => get_header(headers, "content-type"),
89+
"server" => get_header(headers, "server")
90+
}
91+
92+
{:error, _reason} ->
93+
case Req.get(url, receive_timeout: @probe_timeout, retry: false, redirect: true, max_redirects: 3) do
94+
{:ok, %{status: status, headers: headers}} ->
95+
%{
96+
"http_status" => status,
97+
"content_type" => get_header(headers, "content-type"),
98+
"server" => get_header(headers, "server")
99+
}
100+
101+
{:error, reason} ->
102+
%{
103+
"http_status" => nil,
104+
"content_type" => nil,
105+
"server" => nil,
106+
"error" => inspect(reason)
107+
}
108+
end
109+
end
110+
end
111+
112+
@spec derive_base_url(String.t()) :: String.t()
113+
defp derive_base_url(url) do
114+
uri = URI.parse(url)
115+
116+
port_suffix =
117+
case {uri.scheme, uri.port} do
118+
{"https", 443} -> ""
119+
{"http", 80} -> ""
120+
{_, nil} -> ""
121+
{_, port} -> ":#{port}"
122+
end
123+
124+
"#{uri.scheme}://#{uri.host}#{port_suffix}"
125+
end
126+
127+
@spec discover_openapi(String.t(), String.t()) :: map() | nil
128+
defp discover_openapi(base_url, full_url) do
129+
# Try spec paths relative to the full URL first, then the base host
130+
candidates =
131+
Enum.map(@spec_paths, fn path -> String.trim_trailing(full_url, "/") <> path end) ++
132+
if full_url != base_url do
133+
Enum.map(@spec_paths, fn path -> base_url <> path end)
134+
else
135+
[]
136+
end
137+
138+
candidates
139+
|> Enum.uniq()
140+
|> Enum.find_value(fn spec_url ->
141+
case Req.get(spec_url,
142+
receive_timeout: @probe_timeout,
143+
retry: false,
144+
max_retries: 0
145+
) do
146+
{:ok, %{status: 200, body: body}} when is_map(body) ->
147+
# Req auto-decoded JSON — check if it's an OpenAPI/Swagger spec
148+
if Map.has_key?(body, "openapi") or Map.has_key?(body, "swagger") do
149+
parse_openapi_spec(body, spec_url)
150+
end
151+
152+
{:ok, %{status: 200, body: body}} when is_binary(body) ->
153+
# Non-JSON response or Req didn't decode — try manual parse
154+
if byte_size(body) <= @spec_max_bytes do
155+
case Jason.decode(body) do
156+
{:ok, json} when is_map(json) ->
157+
if Map.has_key?(json, "openapi") or Map.has_key?(json, "swagger") do
158+
parse_openapi_spec(json, spec_url)
159+
end
160+
161+
_ ->
162+
nil
163+
end
164+
end
165+
166+
_ ->
167+
nil
168+
end
169+
end)
170+
end
171+
172+
@spec parse_openapi_spec(map(), String.t()) :: map()
173+
defp parse_openapi_spec(spec, spec_url) do
174+
%{
175+
"spec_url" => spec_url,
176+
"title" => get_in(spec, ["info", "title"]),
177+
"version" => get_in(spec, ["info", "version"]),
178+
"base_path" => extract_base_path(spec),
179+
"auth_schemes" => extract_auth_schemes(spec),
180+
"endpoints" => extract_endpoints(spec)
181+
}
182+
end
183+
184+
defp extract_base_path(spec) do
185+
cond do
186+
# OpenAPI 3.x — servers[0].url
187+
servers = spec["servers"] ->
188+
case List.first(servers || []) do
189+
%{"url" => url} when is_binary(url) ->
190+
uri = URI.parse(url)
191+
uri.path || ""
192+
193+
_ ->
194+
""
195+
end
196+
197+
# Swagger 2.x — basePath
198+
base_path = spec["basePath"] ->
199+
base_path
200+
201+
true ->
202+
""
203+
end
204+
end
205+
206+
defp extract_auth_schemes(spec) do
207+
schemes =
208+
cond do
209+
# OpenAPI 3.x
210+
components = get_in(spec, ["components", "securitySchemes"]) ->
211+
Map.keys(components)
212+
213+
# Swagger 2.x
214+
definitions = spec["securityDefinitions"] ->
215+
Map.keys(definitions)
216+
217+
true ->
218+
[]
219+
end
220+
221+
Enum.take(schemes, 20)
222+
end
223+
224+
defp extract_endpoints(spec) do
225+
paths = spec["paths"] || %{}
226+
227+
http_methods = ~w(get post put patch delete head options)
228+
229+
paths
230+
|> Enum.flat_map(fn {path, methods} ->
231+
methods
232+
|> Enum.filter(fn {method, _} -> method in http_methods end)
233+
|> Enum.map(fn {method, details} ->
234+
%{
235+
"method" => String.upcase(method),
236+
"path" => path,
237+
"summary" => extract_summary(details)
238+
}
239+
end)
240+
end)
241+
|> Enum.sort_by(fn ep -> {ep["path"], ep["method"]} end)
242+
|> Enum.take(@max_endpoints)
243+
end
244+
245+
defp extract_summary(details) when is_map(details) do
246+
details["summary"] || details["description"] || ""
247+
end
248+
249+
defp extract_summary(_), do: ""
250+
251+
defp get_header(headers, name) when is_map(headers) do
252+
Map.get(headers, name)
253+
end
254+
255+
defp get_header(headers, name) when is_list(headers) do
256+
case List.keyfind(headers, name, 0) do
257+
{_, value} -> value
258+
nil -> nil
259+
end
260+
end
261+
262+
defp get_header(_, _), do: nil
263+
264+
defp mark_status(resource, status) do
265+
existing = resource.metadata || %{}
266+
discovery = Map.get(existing, "discovery", %{})
267+
updated_discovery = Map.put(discovery, "status", status)
268+
updated_metadata = Map.put(existing, "discovery", updated_discovery)
269+
270+
Resources.update_resource(resource, %{metadata: updated_metadata}, skip_discovery: true)
271+
broadcast(resource.id, :running)
272+
end
273+
274+
defp save_discovery(resource, discovery_map) do
275+
case Resources.get_resource(resource.id) do
276+
{:ok, fresh_resource} ->
277+
existing = fresh_resource.metadata || %{}
278+
updated = Map.put(existing, "discovery", discovery_map)
279+
Resources.update_resource(fresh_resource, %{metadata: updated}, skip_discovery: true)
280+
281+
{:error, :not_found} ->
282+
Logger.warning("[ApiDiscovery] Resource #{resource.id} no longer exists, skipping save")
283+
end
284+
end
285+
286+
defp broadcast(resource_id, status) do
287+
Phoenix.PubSub.broadcast(@pubsub, @discovery_topic, {:discovery_updated, resource_id, status})
288+
end
289+
end

lib/alex_claw/skills/api_request.ex

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ defmodule AlexClaw.Skills.ApiRequest do
5050
def run(args) do
5151
config = args[:config] || %{}
5252
input = args[:input]
53+
resources = args[:resources] || []
54+
55+
api_resource = find_api_resource(resources)
56+
config = enrich_config(config, api_resource)
5357

5458
method = String.upcase(config["method"] || "GET")
5559
url = interpolate(config["url"] || "", input)
@@ -67,6 +71,48 @@ defmodule AlexClaw.Skills.ApiRequest do
6771
end
6872
end
6973

74+
defp find_api_resource(resources) when is_list(resources) do
75+
Enum.find(resources, fn r -> r.type == "api" and r.enabled end)
76+
end
77+
78+
defp find_api_resource(_), do: nil
79+
80+
defp enrich_config(config, nil), do: config
81+
82+
defp enrich_config(config, resource) do
83+
base_url = get_in(resource.metadata || %{}, ["discovery", "base_url"]) || resource.url || ""
84+
base_path = get_in(resource.metadata || %{}, ["discovery", "openapi", "base_path"]) || ""
85+
86+
config
87+
|> resolve_url(base_url, base_path)
88+
|> merge_auth_headers(resource.metadata)
89+
end
90+
91+
defp resolve_url(%{"url" => url} = config, base_url, base_path)
92+
when is_binary(url) and url != "" do
93+
if String.contains?(url, "{base_url}") do
94+
Map.put(config, "url", String.replace(url, "{base_url}", base_url <> base_path))
95+
else
96+
config
97+
end
98+
end
99+
100+
defp resolve_url(%{"path" => path} = config, base_url, base_path) when is_binary(path) do
101+
config
102+
|> Map.put("url", base_url <> base_path <> path)
103+
|> Map.delete("path")
104+
end
105+
106+
defp resolve_url(config, _base_url, _base_path), do: config
107+
108+
defp merge_auth_headers(config, %{"auth" => %{"header" => name, "value" => value}})
109+
when is_binary(name) and is_binary(value) do
110+
existing = config["headers"] || %{}
111+
Map.put(config, "headers", Map.put_new(existing, name, value))
112+
end
113+
114+
defp merge_auth_headers(config, _metadata), do: config
115+
70116
defp execute_request(method, url, headers, body) do
71117
Logger.info("ApiRequest #{method} #{url}", skill: :api_request)
72118

0 commit comments

Comments
 (0)