# frozen_string_literal: true
require 'net/http'
require 'uri'
require 'json'
require 'rexml/document'
module KPM
module NexusFacade
class MavenCentralApiCalls < NexusApiCallsV2
READ_TIMEOUT_DEFAULT = 60
OPEN_TIMEOUT_DEFAULT = 60
attr_reader :configuration
attr_accessor :logger
BASE_REPO_URL = 'https://repo1.maven.org/maven2'
SEARCH_API = 'https://search.maven.org/solrsearch/select'
def initialize(configuration, _ssl_verify, logger)
@configuration = configuration
@configuration[:url] ||= BASE_REPO_URL
@logger = logger
end
def search_for_artifacts(coordinates)
artifact = parse_coordinates(coordinates)
params = {
q: "g:\"#{artifact[:group_id]}\" AND a:\"#{artifact[:artifact_id]}\"",
rows: 200,
wt: 'json',
core: 'gav'
}
query = params.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join('&')
url = "#{SEARCH_API}?#{query}"
response = Net::HTTP.get_response(URI(url))
raise "Search failed: #{response.code}" unless response.code.to_i == 200
json = JSON.parse(response.body)
docs = json['response']['docs']
search_versions = docs.map { |doc| doc['v'] }.uniq
# Apply when the artifact provided
if artifact[:artifact_id]
# Fetch metadata versions (incase the artifact is not indexed)
metadata_url = build_metadata_url(artifact)
metadata_versions = []
begin
metadata_response = Net::HTTP.get(URI(metadata_url))
if metadata_response.nil? || metadata_response.strip.empty?
logger.debug { "Empty metadata response for #{artifact[:artifact_id]}" }
else
begin
metadata_xml = REXML::Document.new(metadata_response)
metadata_xml.elements.each('//versioning/versions/version') do |version_node|
metadata_versions << version_node.text
end
rescue REXML::ParseException => e
logger.debug { "Malformed XML in metadata for #{artifact[:artifact_id]}: #{e.message}" }
end
end
rescue StandardError => e
logger.debug { "Failed to fetch metadata for #{artifact[:artifact_id]}: #{e.message}" }
end
# Combine versions
search_versions = (search_versions + metadata_versions).uniq.sort_by do |v|
begin
Gem::Version.new(v)
rescue ArgumentError
v
end
end.reverse
artifacts_xml = '<searchNGResponse><data>'
search_versions.each do |version|
artifacts_xml += '<artifact>'
artifacts_xml += "<groupId>#{artifact[:group_id]}</groupId>"
artifacts_xml += "<artifactId>#{artifact[:artifact_id]}</artifactId>"
artifacts_xml += "<version>#{version}</version>"
artifacts_xml += '<repositoryId>central</repositoryId>'
artifacts_xml += '</artifact>'
end
else # Incase no artifact_id is provided for plugin search
artifacts_xml = '<searchNGResponse><data>'
docs.each do |doc|
artifacts_xml += '<artifact>'
artifacts_xml += "<groupId>#{doc['g']}</groupId>"
artifacts_xml += "<artifactId>#{doc['a']}</artifactId>"
artifacts_xml += "<version>#{doc['v']}</version>"
artifacts_xml += '<repositoryId>central</repositoryId>'
artifacts_xml += '</artifact>'
end
end
artifacts_xml += '</data></searchNGResponse>'
artifacts_xml
end
def get_artifact_info(coordinates)
coords = parse_coordinates(coordinates)
version = coords[:version]
if version.casecmp('latest').zero?
version = fetch_latest_version(coords)
coords = coords.merge(version: version)
end
_, versioned_artifact, coords = build_base_path_and_coords([coords[:group_id], coords[:artifact_id], coords[:extension], coords[:classifier], version].compact.join(':'))
sha1 = get_sha1([coords[:group_id], coords[:artifact_id], coords[:extension], coords[:classifier], version].compact.join(':'))
artifact_xml = '<artifact-resolution><data>'
artifact_xml += '<presentLocally>true</presentLocally>'
artifact_xml += "<groupId>#{coords[:group_id]}</groupId>"
artifact_xml += "<artifactId>#{coords[:artifact_id]}</artifactId>"
artifact_xml += "<version>#{coords[:version]}</version>"
artifact_xml += "<extension>#{coords[:extension]}</extension>"
artifact_xml += "<snapshot>#{!(coords[:version] =~ /-SNAPSHOT$/).nil?}</snapshot>"
artifact_xml += "<sha1>#{sha1}</sha1>"
artifact_xml += "<repositoryPath>/#{coords[:group_id].gsub('.', '/')}/#{versioned_artifact}</repositoryPath>"
artifact_xml += '</data></artifact-resolution>'
artifact_xml
end
def pull_artifact(coordinates, destination)
artifact = parse_coordinates(coordinates)
version = artifact[:version]
version = fetch_latest_version(artifact) if version.casecmp('latest').zero?
file_name = build_file_name(artifact[:artifact_id], version, artifact[:classifier], artifact[:extension])
download_url = build_download_url(artifact, version, file_name)
dest_path = File.join(File.expand_path(destination || '.'), file_name)
File.open(dest_path, 'wb') do |io|
io.write(Net::HTTP.get(URI(download_url)))
end
{
file_name: file_name,
file_path: File.expand_path(dest_path),
version: version,
size: File.size(File.expand_path(dest_path))
}
end
private
def parse_coordinates(coordinates)
raise 'Invalid coordinates' if coordinates.nil?
parts = coordinates.split(':')
{
group_id: parts[0],
artifact_id: parts[1],
extension: parts.size > 3 ? parts[2] : 'jar',
classifier: parts.size > 4 ? parts[3] : nil,
version: parts[-1]
}
end
def build_base_path_and_coords(coordinates)
coords = parse_coordinates(coordinates)
token_org_and_repo = URI.parse(configuration[:url]).path
[
"#{token_org_and_repo}/#{coords[:group_id].gsub('.', '/')}/#{coords[:artifact_id]}",
"#{coords[:version]}/#{coords[:artifact_id]}-#{coords[:version]}.#{coords[:extension]}",
coords
]
end
def get_sha1(coordinates)
base_path, versioned_artifact, = build_base_path_and_coords(coordinates)
endpoint = "#{base_path}/#{versioned_artifact}.sha1"
get_response_with_retries(nil, endpoint, nil)
end
def get_response_with_retries(coordinates, endpoint, what_parameters)
logger.debug { "Fetching coordinates=#{coordinates}, endpoint=#{endpoint}, params=#{what_parameters}" }
response = get_response(coordinates, endpoint, what_parameters)
logger.debug { "Response body: #{response.body}" }
process_response_with_retries(response)
end
def process_response_with_retries(response)
case response.code
when '200'
response.body
when '301', '302', '307'
location = response['Location']
logger.debug { "Following redirect to #{location}" }
new_path = location.gsub!(configuration[:url], '')
if new_path.nil?
# Redirect to another domain (e.g. CDN)
get_raw_response_with_retries(location)
else
get_response_with_retries(nil, location, nil)
end
when '404'
raise StandardError, ERROR_MESSAGE_404
else
raise UnexpectedStatusCodeException, response.code
end
end
def get_response(coordinates, endpoint, what_parameters)
http = build_http
query_params = build_query_params(coordinates, what_parameters) unless coordinates.nil?
endpoint = endpoint_with_params(endpoint, query_params) unless coordinates.nil?
request = Net::HTTP::Get.new(endpoint)
if configuration.key?(:username) && configuration.key?(:password)
request.basic_auth(configuration[:username], configuration[:password])
elsif configuration.key?(:token)
request['Authorization'] = "token #{configuration[:token]}"
end
logger.debug do
http.set_debug_output(logger)
"HTTP path: #{endpoint}"
end
http.request(request)
end
def build_metadata_url(artifact)
group_path = artifact[:group_id].tr('.', '/')
"#{BASE_REPO_URL}/#{group_path}/#{artifact[:artifact_id]}/maven-metadata.xml"
end
def fetch_latest_version(artifact)
xml = REXML::Document.new(Net::HTTP.get(URI(build_metadata_url(artifact))))
xml.elements['//versioning/latest']&.text || xml.elements['//version']&.text
end
def build_file_name(artifact_id, version, classifier, extension)
classifier ? "#{artifact_id}-#{version}-#{classifier}.#{extension}" : "#{artifact_id}-#{version}.#{extension}"
end
def build_download_url(artifact, version, file_name)
group_path = artifact[:group_id].tr('.', '/')
"#{BASE_REPO_URL}/#{group_path}/#{artifact[:artifact_id]}/#{version}/#{file_name}"
end
end
end
end