|
| 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 |
0 commit comments