001/* 002 * SPDX-License-Identifier: Apache-2.0 003 * 004 * Copyright 2025-2026 The Enola <https://enola.dev> Authors 005 * 006 * Licensed under the Apache License, Version 2.0 (the "License"); 007 * you may not use this file except in compliance with the License. 008 * You may obtain a copy of the License at 009 * 010 * https://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package dev.enola.connect.maven; 019 020import static com.google.common.base.Strings.isNullOrEmpty; 021 022import com.github.packageurl.MalformedPackageURLException; 023import com.github.packageurl.PackageURL; 024import com.github.packageurl.PackageURLBuilder; 025import com.google.common.base.Strings; 026 027import eu.maveniverse.maven.mima.extensions.mmr.ModelResponse; 028 029import org.eclipse.aether.artifact.Artifact; 030import org.eclipse.aether.artifact.DefaultArtifact; 031 032import java.util.Map; 033 034/** 035 * GAVR is a Maven GroupID, ArtifactID, Version, Extension (AKA Type), Classifier + Repository. 036 * 037 * <p>The GroupID, ArtifactID & Version are mandatory and cannot be empty. The Extension, 038 * Classifier & Repository can be empty (but never null). 039 * 040 * <p>The Maven default extension "jar" are hidden in the GAV coordinates and Package URL syntax 041 * (but is returned by {@link #extension()} API). This is different from Maven core, which always 042 * shows "jar". 043 * 044 * <p>This class itself does NOT imply any other "defaults" for Classifier & Repository. Callers 045 * of this class may resolve a GAVR without repo to one with a repo using {@link 046 * Mima#origin(ModelResponse)}. 047 */ 048public record GAVR( 049 String groupId, 050 String artifactId, 051 String extension, 052 String classifier, 053 String version, 054 String repo) { 055 056 // TODO Consider #performance - make this a class to cache GAV & PkgURL its representations? 057 058 /** 059 * Parse a coordinates in the {@code 060 * <groupId>:<artifactId>[:<extension>[:<classifier>]]:<version>} format, like e.g. 061 * "ch.vorburger.mariaDB4j:mariaDB4j-core:3.1.0" to a GAVR. 062 * 063 * <p>This format is not a URL; use {@link #parsePkgURL(String)} if you have a URL, or {@link 064 * #toPkgURL()} if you want one. 065 * 066 * <p>PS: This syntax does not allow specifying a repository! 067 */ 068 public static GAVR parseGAV(String gav) { 069 var artifact = new DefaultArtifact(gav); 070 return new GAVR( 071 artifact.getGroupId(), 072 artifact.getArtifactId(), 073 artifact.getExtension(), 074 artifact.getClassifier(), 075 artifact.getVersion(), 076 ""); 077 } 078 079 /** Return a String in the same format that {@link #parsePkgURL(String)} uses. */ 080 public String toPkgURL() { 081 var builder = PackageURLBuilder.aPackageURL(); 082 builder.withType("maven"); 083 builder.withNamespace(groupId); 084 builder.withName(artifactId); 085 builder.withVersion(version); 086 if (!extension.isEmpty() && !"jar".equals(extension)) { 087 builder.withQualifier("type", extension); 088 } 089 if (!classifier.isEmpty()) { 090 builder.withQualifier("classifier", classifier); 091 } 092 if (!repo.isEmpty()) { 093 builder.withQualifier("repository_url", repo); 094 } 095 try { 096 return builder.build().canonicalize(); 097 } catch (MalformedPackageURLException e) { 098 throw new IllegalStateException(toGAV(), e); 099 } 100 } 101 102 /** 103 * Parse a Maven Package URL. For example, 104 * "pkg:maven/ch.vorburger.mariaDB4j/mariaDB4j-core@3.1.0?classifier=javadoc". See <a 105 * href="https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#maven">Type 106 * definition</a> and its underlying <a 107 * href="https://spdx.github.io/spdx-spec/v3.0.1/annexes/pkg-url-specification/">SPDX 108 * specification</a>. 109 */ 110 public static GAVR parsePkgURL(String purl) { 111 try { 112 var p = new PackageURL(purl); 113 var q = p.getQualifiers(); 114 if (!"maven".equals(p.getType())) throw new IllegalArgumentException(purl); 115 // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#maven 116 return new GAVR( 117 p.getNamespace(), 118 p.getName(), 119 get(q, "type"), 120 get(q, "classifier"), 121 p.getVersion(), 122 get(q, "repository_url")); 123 124 } catch (MalformedPackageURLException e) { 125 throw new IllegalArgumentException(purl, e); 126 } 127 } 128 129 private static String get(Map<String, String> q, String key) { 130 if (q == null) return ""; 131 var value = q.get(key); 132 return Strings.nullToEmpty(value); 133 } 134 135 public static class Builder { 136 private String groupId; 137 private String artifactId; 138 private String extension; 139 private String classifier; 140 private String version; 141 private String repo; 142 143 public Builder groupId(String groupId) { 144 this.groupId = groupId; 145 return this; 146 } 147 148 public Builder artifactId(String artifactId) { 149 this.artifactId = artifactId; 150 return this; 151 } 152 153 public Builder extension(String extension) { 154 this.extension = extension; 155 return this; 156 } 157 158 public Builder classifier(String classifier) { 159 this.classifier = classifier; 160 return this; 161 } 162 163 public Builder version(String version) { 164 this.version = version; 165 return this; 166 } 167 168 public Builder repo(String repo) { 169 this.repo = repo; 170 return this; 171 } 172 173 public GAVR build() { 174 return new GAVR( 175 groupId, 176 artifactId, 177 nullToEmpty(extension), 178 nullToEmpty(classifier), 179 version, 180 nullToEmpty(repo)); 181 } 182 183 private String nullToEmpty(String string) { 184 if (string == null) return ""; 185 else return string; 186 } 187 } 188 189 public GAVR { 190 requireNonEmpty(groupId, "groupId"); 191 requireNonEmpty(artifactId, "artifactId"); 192 extension = useDefaultIfNullOrEmpty(extension, "jar"); 193 requireNonNull(classifier, "classifier"); 194 requireNonEmpty(version, "version"); 195 requireNonNull(repo, "repo"); 196 } 197 198 private String useDefaultIfNullOrEmpty(String string, String defaultValue) { 199 if (string == null) return defaultValue; 200 if (string.isEmpty()) return defaultValue; 201 return string; 202 } 203 204 private void requireNonNull(Object object, String field) { 205 if (object == null) throw new IllegalStateException(field + " cannot be null"); 206 } 207 208 private void requireNonEmpty(String string, String field) { 209 if (isNullOrEmpty(string)) 210 throw new IllegalStateException(field + " cannot be null or empty"); 211 } 212 213 /** 214 * Return a String in the same format that {@link #parseGAV(String)} uses. 215 * 216 * <p>This omits extension "jar" when it can (contrary to how 217 * org.eclipse.aether.artifact.AbstractArtifact#toString() does it). 218 */ 219 public String toGAV() { 220 var sb = // w.o. repo.length() 221 new StringBuilder( 222 4 // max. 4x ':' 223 + groupId.length() 224 + artifactId.length() 225 + extension.length() 226 + classifier.length() 227 + version.length()); 228 229 sb.append(groupId).append(':').append(artifactId); 230 231 if ((!extension.isEmpty() && !"jar".equals(extension)) 232 || ("jar".equals(extension) && !classifier.isEmpty())) 233 sb.append(':').append(extension); 234 235 if (!classifier.isEmpty()) sb.append(':').append(classifier); 236 237 sb.append(':'); 238 sb.append(version); 239 return sb.toString(); 240 } 241 242 public Builder toBuilder() { 243 return new Builder() 244 .groupId(groupId) 245 .artifactId(artifactId) 246 .extension(extension) 247 .classifier(classifier) 248 .version(version); 249 } 250 251 // NOT public - this is a package private internal method! 252 // NB: Artifact does NOT include the Repository! Callers will use repository() to obtain that. 253 Artifact toArtifact() { 254 return new DefaultArtifact(groupId, artifactId, classifier, extension, version); 255 } 256}