Skip to content

Commit df389f4

Browse files
Jonathan D.A. Jewellclaude
andcommitted
feat: add 15 registry adapters (Packagist, CPAN, CRAN, Conda, CocoaPods, OPAM, Clojars, LuaRocks, Terraform, JSR, Conan, SwiftPM, Elm, vcpkg, JuliaGeneral)
Expands OPSM from 19 to 34 registry adapters covering PHP, Perl, R, scientific Python, iOS/macOS, OCaml, Clojure, Lua, IaC, Deno, C/C++, Swift, Elm, and Julia ecosystems. Updates dispatcher and validation modules with all new forths and aliases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1c51e1d commit df389f4

17 files changed

Lines changed: 3514 additions & 7 deletions
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
defmodule Opsm.Registries.Clojars do
3+
@moduledoc """
4+
Clojars registry API client.
5+
https://clojars.org/
6+
Clojars is the community-driven Clojure library repository.
7+
"""
8+
9+
alias Opsm.Types.{ManifestFormat, ResolvedPackage}
10+
alias Opsm.Verified.Http, as: VerifiedHttp
11+
12+
@api_url "https://clojars.org/api"
13+
@repo_url "https://repo.clojars.org"
14+
@web_url "https://clojars.org"
15+
16+
@doc """
17+
Fetch artifact metadata from Clojars.
18+
"""
19+
def fetch_package(name, version \\ "latest") do
20+
# Clojars artifacts can be grouped (e.g., "org.clojure/clojure") or simple (e.g., "compojure")
21+
{group_id, artifact_id} = parse_artifact_name(name)
22+
23+
target_version = if version == "latest" do
24+
fetch_latest_version(group_id, artifact_id)
25+
else
26+
version
27+
end
28+
29+
case target_version do
30+
nil ->
31+
{:error, :not_found}
32+
33+
ver ->
34+
# Fetch artifact metadata
35+
url = build_artifact_url(group_id, artifact_id)
36+
case VerifiedHttp.get_json(url, receive_timeout: 10_000) do
37+
{:ok, body} ->
38+
{:ok, parse_artifact(name, body, ver)}
39+
40+
{:error, :not_found} ->
41+
{:error, :not_found}
42+
43+
{:error, %{status: 404}} ->
44+
{:error, :not_found}
45+
46+
{:error, %{status: status}} ->
47+
{:error, "Clojars API returned status #{status}"}
48+
49+
{:error, reason} ->
50+
{:error, reason}
51+
end
52+
end
53+
end
54+
55+
defp fetch_latest_version(group_id, artifact_id) do
56+
url = build_artifact_url(group_id, artifact_id)
57+
case VerifiedHttp.get_json(url, receive_timeout: 10_000) do
58+
{:ok, %{"latest_version" => version}} -> version
59+
{:ok, %{"versions" => [latest | _]}} -> latest
60+
_ -> nil
61+
end
62+
end
63+
64+
defp parse_artifact_name(name) do
65+
case String.split(name, "/", parts: 2) do
66+
[group, artifact] -> {group, artifact}
67+
[artifact] -> {artifact, artifact} # Simple artifact name uses itself as group
68+
end
69+
end
70+
71+
defp build_artifact_url(group_id, artifact_id) do
72+
if group_id == artifact_id do
73+
"#{@api_url}/artifacts/#{artifact_id}"
74+
else
75+
"#{@api_url}/artifacts/#{group_id}/#{artifact_id}"
76+
end
77+
end
78+
79+
@doc """
80+
Search for Clojars artifacts.
81+
"""
82+
def search(query, opts \\ []) do
83+
limit = Keyword.get(opts, :limit, 20)
84+
85+
url = "#{@api_url}/search?q=#{URI.encode_www_form(query)}&format=json"
86+
87+
case VerifiedHttp.get_json(url, receive_timeout: 10_000) do
88+
{:ok, %{"results" => results}} when is_list(results) ->
89+
packages = results
90+
|> Enum.take(limit)
91+
|> Enum.map(&parse_search_result/1)
92+
{:ok, packages}
93+
94+
{:ok, results} when is_list(results) ->
95+
packages = results
96+
|> Enum.take(limit)
97+
|> Enum.map(&parse_search_result/1)
98+
{:ok, packages}
99+
100+
{:error, :not_found} ->
101+
{:ok, []}
102+
103+
{:error, %{status: 404}} ->
104+
{:ok, []}
105+
106+
{:error, reason} ->
107+
{:error, reason}
108+
end
109+
end
110+
111+
defp parse_search_result(result) do
112+
%{
113+
name: build_full_name(result),
114+
version: Map.get(result, "version") || Map.get(result, "latest_version"),
115+
description: Map.get(result, "description") || "",
116+
downloads: Map.get(result, "downloads", 0)
117+
}
118+
end
119+
120+
defp build_full_name(%{"group_name" => group, "jar_name" => artifact}) when group != artifact do
121+
"#{group}/#{artifact}"
122+
end
123+
defp build_full_name(%{"jar_name" => artifact}), do: artifact
124+
defp build_full_name(%{"artifact_id" => artifact}), do: artifact
125+
defp build_full_name(_), do: "unknown"
126+
127+
@doc """
128+
Check if a Clojars artifact exists.
129+
"""
130+
def exists?(name) do
131+
{group_id, artifact_id} = parse_artifact_name(name)
132+
url = build_artifact_url(group_id, artifact_id)
133+
134+
case VerifiedHttp.get(url, receive_timeout: 5_000) do
135+
{:ok, _} -> true
136+
_ -> false
137+
end
138+
end
139+
140+
@doc """
141+
Get all versions of a Clojars artifact.
142+
"""
143+
def versions(name) do
144+
{group_id, artifact_id} = parse_artifact_name(name)
145+
url = build_artifact_url(group_id, artifact_id)
146+
147+
case VerifiedHttp.get_json(url, receive_timeout: 10_000) do
148+
{:ok, %{"versions" => versions}} when is_list(versions) ->
149+
# Reverse to get newest first
150+
{:ok, Enum.reverse(versions)}
151+
152+
{:ok, %{"recent_versions" => versions}} when is_list(versions) ->
153+
# Some endpoints might only return recent versions
154+
versions_list = versions
155+
|> Enum.map(fn
156+
%{"version" => v} -> v
157+
v when is_binary(v) -> v
158+
_ -> nil
159+
end)
160+
|> Enum.reject(&is_nil/1)
161+
{:ok, Enum.reverse(versions_list)}
162+
163+
{:error, :not_found} ->
164+
{:error, :not_found}
165+
166+
{:error, %{status: 404}} ->
167+
{:error, :not_found}
168+
169+
{:error, reason} ->
170+
{:error, reason}
171+
172+
_ ->
173+
{:ok, []}
174+
end
175+
end
176+
177+
@doc """
178+
Get JAR URL for a specific version.
179+
Clojars uses Maven repository layout.
180+
"""
181+
def tarball_url(name, version) do
182+
{group_id, artifact_id} = parse_artifact_name(name)
183+
# Convert group_id dots to slashes for Maven path
184+
group_path = String.replace(group_id, ".", "/")
185+
jar_url = "#{@repo_url}/#{group_path}/#{artifact_id}/#{version}/#{artifact_id}-#{version}.jar"
186+
{:ok, jar_url}
187+
end
188+
189+
# Parsers
190+
191+
defp parse_artifact(name, metadata, version) do
192+
{group_id, artifact_id} = parse_artifact_name(name)
193+
194+
# Extract dependencies from the version metadata if available
195+
deps = extract_dependencies(metadata, version)
196+
197+
# Get JAR URL
198+
{:ok, jar_url} = tarball_url(name, version)
199+
200+
# Build web URL for the artifact
201+
web_url = if group_id == artifact_id do
202+
"#{@web_url}/#{artifact_id}"
203+
else
204+
"#{@web_url}/#{group_id}/#{artifact_id}"
205+
end
206+
207+
# Repository URL (often hosted on GitHub)
208+
repo_url = case metadata do
209+
%{"scm" => %{"url" => url}} -> url
210+
%{"homepage" => url} when is_binary(url) -> url
211+
_ -> nil
212+
end
213+
214+
%ResolvedPackage{
215+
package: name,
216+
version: version,
217+
forth: :clojars,
218+
registry_url: web_url,
219+
tarball_url: jar_url,
220+
checksum: nil, # Clojars API doesn't provide checksums directly
221+
checksum_algo: nil,
222+
manifest: %ManifestFormat{
223+
name: name,
224+
version: version,
225+
description: Map.get(metadata, "description"),
226+
license: extract_license(metadata),
227+
homepage: Map.get(metadata, "homepage") || web_url,
228+
repository: repo_url,
229+
authors: extract_authors(metadata),
230+
keywords: [],
231+
dependencies: deps,
232+
dev_dependencies: %{},
233+
source_forth: :clojars,
234+
raw_manifest: metadata
235+
},
236+
attestations: [],
237+
resolved_deps: []
238+
}
239+
end
240+
241+
defp extract_dependencies(metadata, version) do
242+
# Try to find version-specific dependencies
243+
case metadata do
244+
%{"recent_versions" => versions} when is_list(versions) ->
245+
versions
246+
|> Enum.find(%{}, fn v ->
247+
Map.get(v, "version") == version
248+
end)
249+
|> Map.get("dependencies", %{})
250+
|> normalize_deps()
251+
252+
%{"dependencies" => deps} ->
253+
normalize_deps(deps)
254+
255+
_ ->
256+
%{}
257+
end
258+
end
259+
260+
defp normalize_deps(deps) when is_map(deps) do
261+
# Dependencies might be in format: {"group/artifact" => "version"}
262+
# or [{"group/artifact", "version"}]
263+
deps
264+
end
265+
defp normalize_deps(deps) when is_list(deps) do
266+
# Convert list of tuples to map
267+
Enum.into(deps, %{})
268+
end
269+
defp normalize_deps(_), do: %{}
270+
271+
defp extract_license(metadata) do
272+
case metadata do
273+
%{"licenses" => [license | _]} when is_binary(license) -> license
274+
%{"licenses" => [%{"name" => name} | _]} -> name
275+
%{"license" => license} when is_binary(license) -> license
276+
_ -> nil
277+
end
278+
end
279+
280+
defp extract_authors(metadata) do
281+
case metadata do
282+
%{"authors" => authors} when is_list(authors) -> authors
283+
%{"author" => author} when is_binary(author) -> [author]
284+
_ -> []
285+
end
286+
end
287+
end

0 commit comments

Comments
 (0)