001/* 002 * JDrupes Builder 003 * Copyright (C) 2025 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.mvnrepo; 020 021import java.io.BufferedInputStream; 022import java.io.BufferedOutputStream; 023import java.io.FileNotFoundException; 024import java.io.IOException; 025import java.io.InputStream; 026import java.io.OutputStream; 027import java.io.PipedInputStream; 028import java.io.PipedOutputStream; 029import java.io.UncheckedIOException; 030import java.io.UnsupportedEncodingException; 031import java.net.URI; 032import java.net.URISyntaxException; 033import java.net.URLEncoder; 034import java.net.http.HttpClient; 035import java.net.http.HttpRequest; 036import java.net.http.HttpResponse; 037import java.nio.charset.StandardCharsets; 038import java.nio.file.Files; 039import java.nio.file.Path; 040import java.security.MessageDigest; 041import java.security.NoSuchAlgorithmException; 042import java.security.Security; 043import java.util.ArrayList; 044import java.util.List; 045import java.util.Optional; 046import java.util.concurrent.ExecutorService; 047import java.util.concurrent.Executors; 048import java.util.concurrent.atomic.AtomicBoolean; 049import java.util.function.Supplier; 050import java.util.stream.Stream; 051import java.util.zip.ZipEntry; 052import java.util.zip.ZipOutputStream; 053import org.apache.maven.model.building.DefaultModelBuilderFactory; 054import org.apache.maven.model.building.DefaultModelBuildingRequest; 055import org.apache.maven.model.building.ModelBuildingException; 056import org.bouncycastle.bcpg.ArmoredOutputStream; 057import org.bouncycastle.jce.provider.BouncyCastleProvider; 058import org.bouncycastle.openpgp.PGPException; 059import org.bouncycastle.openpgp.PGPPrivateKey; 060import org.bouncycastle.openpgp.PGPPublicKey; 061import org.bouncycastle.openpgp.PGPSecretKeyRingCollection; 062import org.bouncycastle.openpgp.PGPSignature; 063import org.bouncycastle.openpgp.PGPSignatureGenerator; 064import org.bouncycastle.openpgp.PGPUtil; 065import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; 066import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; 067import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; 068import org.bouncycastle.util.encoders.Base64; 069import org.eclipse.aether.AbstractRepositoryListener; 070import org.eclipse.aether.DefaultRepositorySystemSession; 071import org.eclipse.aether.RepositoryEvent; 072import org.eclipse.aether.artifact.Artifact; 073import org.eclipse.aether.artifact.DefaultArtifact; 074import org.eclipse.aether.deployment.DeployRequest; 075import org.eclipse.aether.deployment.DeploymentException; 076import org.eclipse.aether.repository.RemoteRepository; 077import org.eclipse.aether.util.artifact.SubArtifact; 078import org.eclipse.aether.util.repository.AuthenticationBuilder; 079import org.jdrupes.builder.api.BuildException; 080import org.jdrupes.builder.api.Generator; 081import static org.jdrupes.builder.api.Intend.*; 082import org.jdrupes.builder.api.Project; 083import org.jdrupes.builder.api.Resource; 084import org.jdrupes.builder.api.ResourceRequest; 085import static org.jdrupes.builder.api.ResourceRequest.requestFor; 086import org.jdrupes.builder.core.AbstractGenerator; 087import org.jdrupes.builder.java.JavadocJarFile; 088import org.jdrupes.builder.java.LibraryJarFile; 089import org.jdrupes.builder.java.SourcesJarFile; 090import static org.jdrupes.builder.mvnrepo.MvnProperties.ArtifactId; 091import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 092 093/// A [Generator] for maven deployments in response to requests for 094/// [MvnPublication] It supports publishing releases using the 095/// [Publish Portal API](https://central.sonatype.org/publish/publish-portal-api/) 096/// and publishing snapshots using the "traditional" maven approach 097/// (uploading the files, including the appropriate `maven-metadata.xml` 098/// files). 099/// 100/// The publisher requests the [PomFile] from the project and uses 101/// the groupId, artfactId and version as specified in this file. 102/// It also requests the [LibraryJarFile], the [SourcesJarFile] and 103/// the [JavadocJarFile]. The latter two are optional for snapshot 104/// releases. 105/// 106@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports", 107 "PMD.GodClass", "PMD.TooManyMethods" }) 108public class MvnPublisher extends AbstractGenerator { 109 110 private URI uploadUri; 111 private URI snapshotUri; 112 private String repoUser; 113 private String repoPass; 114 private JcaPGPContentSignerBuilder signerBuilder; 115 private PGPPrivateKey privateKey; 116 private PGPPublicKey publicKey; 117 private Supplier<Path> artifactDirectory 118 = () -> project().buildDirectory().resolve("publications/maven"); 119 private boolean keepSubArtifacts; 120 private boolean publishAutomatically; 121 122 /// Creates a new Maven publication generator. 123 /// 124 /// @param project the project 125 /// 126 public MvnPublisher(Project project) { 127 super(project); 128 uploadUri = URI 129 .create("https://central.sonatype.com/api/v1/publisher/upload"); 130 } 131 132 /// Sets the upload URI. 133 /// 134 /// @param uri the repository URI 135 /// @return the maven publication generator 136 /// 137 public MvnPublisher uploadUri(URI uri) { 138 this.uploadUri = uri; 139 return this; 140 } 141 142 /// Returns the upload URI. Defaults to 143 /// `https://central.sonatype.com/api/v1/publisher/upload`. 144 /// 145 /// @return the uri 146 /// 147 public URI uploadUri() { 148 return uploadUri; 149 } 150 151 /// Sets the Maven snapshot repository URI. 152 /// 153 /// @param uri the snapshot repository URI 154 /// @return the maven publication generator 155 /// 156 public MvnPublisher snapshotRepository(URI uri) { 157 this.snapshotUri = uri; 158 return this; 159 } 160 161 /// Returns the snapshot repository. Defaults to 162 /// `https://central.sonatype.com/repository/maven-snapshots/`. 163 /// 164 /// @return the uri 165 /// 166 public URI snapshotRepository() { 167 return snapshotUri; 168 } 169 170 /// Sets the Maven repository credentials. 171 /// 172 /// @param user the username 173 /// @param pass the password 174 /// @return the maven publication generator 175 /// 176 public MvnPublisher credentials(String user, String pass) { 177 this.repoUser = user; 178 this.repoPass = pass; 179 return this; 180 } 181 182 /// Keep generated sub artifacts (checksums, signatures) 183 /// 184 /// @return the mvn publication generator 185 /// 186 public MvnPublisher keepSubArtifacts() { 187 keepSubArtifacts = true; 188 return this; 189 } 190 191 /// Publish the release automatically. 192 /// 193 /// @return the mvn publisher 194 /// 195 public MvnPublisher publishAutomatically() { 196 publishAutomatically = true; 197 return this; 198 } 199 200 /// Returns the directory where additional artifacts are created. 201 /// Defaults to sub directory `publications/maven` in the project's 202 /// build directory (see [Project#buildDirectory]). 203 /// 204 /// @return the directory 205 /// 206 public Path artifactDirectory() { 207 return artifactDirectory.get(); 208 } 209 210 /// Sets the directory where additional artifacts are created. 211 /// The [Path] is resolved against the project's build directory 212 /// (see [Project#buildDirectory]). If `destination` is `null`, 213 /// the additional artifacts are created in the directory where 214 /// the base artifact is found. 215 /// 216 /// @param directory the new directory 217 /// @return the maven publication generator 218 /// 219 public MvnPublisher artifactDirectory(Path directory) { 220 if (directory == null) { 221 this.artifactDirectory = () -> null; 222 return this; 223 } 224 this.artifactDirectory 225 = () -> project().buildDirectory().resolve(directory); 226 return this; 227 } 228 229 /// Sets the directory where additional artifacts are created. 230 /// If the [Supplier] returns `null`, the additional artifacts 231 /// are created in the directory where the base artifact is found. 232 /// 233 /// @param directory the new directory 234 /// @return the maven publication generator 235 /// 236 public MvnPublisher artifactDirectory(Supplier<Path> directory) { 237 this.artifactDirectory = directory; 238 return this; 239 } 240 241 @Override 242 protected <T extends Resource> Stream<T> 243 doProvide(ResourceRequest<T> requested) { 244 if (!requested.includes(MvnPublicationType)) { 245 return Stream.empty(); 246 } 247 PomFile pomResource = resourceCheck(project().supplied( 248 requestFor(PomFile.class)), "POM file"); 249 if (pomResource == null) { 250 return Stream.empty(); 251 } 252 var jarResource = resourceCheck(project().getFrom(project() 253 .providers(Expose, Forward), requestFor(LibraryJarFile.class)), 254 "jar file"); 255 if (jarResource == null) { 256 return Stream.empty(); 257 } 258 var srcsIter = project().supplied(requestFor(SourcesJarFile.class)) 259 .iterator(); 260 SourcesJarFile srcsFile = null; 261 if (srcsIter.hasNext()) { 262 srcsFile = srcsIter.next(); 263 if (srcsIter.hasNext()) { 264 log.severe(() -> "More than one sources jar resources found."); 265 return Stream.empty(); 266 } 267 } 268 var jdIter = project().supplied(requestFor(JavadocJarFile.class)) 269 .iterator(); 270 JavadocJarFile jdFile = null; 271 if (jdIter.hasNext()) { 272 jdFile = jdIter.next(); 273 if (jdIter.hasNext()) { 274 log.severe(() -> "More than one javadoc jar resources found."); 275 return Stream.empty(); 276 } 277 } 278 279 // Deploy what we've found 280 @SuppressWarnings("unchecked") 281 var result = (Stream<T>) deploy(pomResource, jarResource, srcsFile, 282 jdFile); 283 return result; 284 } 285 286 private <T extends Resource> T resourceCheck(Stream<T> resources, 287 String name) { 288 var iter = resources.iterator(); 289 if (!iter.hasNext()) { 290 log.severe(() -> "No " + name + " resource available."); 291 return null; 292 } 293 var result = iter.next(); 294 if (iter.hasNext()) { 295 log.severe(() -> "More than one " + name + " resource found."); 296 return null; 297 } 298 return result; 299 } 300 301 private record Deployable(Artifact artifact, boolean temporary) { 302 } 303 304 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 305 private Stream<?> deploy(PomFile pomResource, LibraryJarFile jarResource, 306 SourcesJarFile srcsJar, JavadocJarFile javadocJar) { 307 Artifact mainArtifact; 308 try { 309 mainArtifact = mainArtifact(pomResource); 310 } catch (ModelBuildingException e) { 311 throw new BuildException( 312 "Cannot build model from POM: " + e.getMessage(), e); 313 } 314 if (artifactDirectory() != null) { 315 artifactDirectory().toFile().mkdirs(); 316 } 317 List<Deployable> toDeploy = new ArrayList<>(); 318 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "pom", 319 pomResource.path().toFile())); 320 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "jar", 321 jarResource.path().toFile())); 322 if (srcsJar != null) { 323 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "sources", 324 "jar", srcsJar.path().toFile())); 325 } 326 if (javadocJar != null) { 327 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "javadoc", 328 "jar", javadocJar.path().toFile())); 329 } 330 331 try { 332 if (mainArtifact.isSnapshot()) { 333 deploySnapshot(toDeploy); 334 } else { 335 deployRelease(mainArtifact, toDeploy); 336 } 337 } catch (DeploymentException e) { 338 throw new BuildException( 339 "Deployment failed for " + mainArtifact, e); 340 } finally { 341 if (!keepSubArtifacts) { 342 toDeploy.stream().filter(Deployable::temporary).forEach(d -> { 343 d.artifact().getFile().delete(); 344 }); 345 } 346 } 347 return Stream.of(project().newResource(MvnPublicationType, 348 mainArtifact.getGroupId() + ":" + mainArtifact.getArtifactId() 349 + ":" + mainArtifact.getVersion())); 350 } 351 352 private Artifact mainArtifact(PomFile pomResource) 353 throws ModelBuildingException { 354 var pomFile = pomResource.path().toFile(); 355 var req = new DefaultModelBuildingRequest().setPomFile(pomFile); 356 var model = new DefaultModelBuilderFactory().newInstance() 357 .build(req).getEffectiveModel(); 358 return new DefaultArtifact(model.getGroupId(), model.getArtifactId(), 359 "jar", model.getVersion()); 360 } 361 362 private void addWithGenerated(List<Deployable> toDeploy, 363 Artifact artifact) { 364 // Add main artifact 365 toDeploy.add(new Deployable(artifact, false)); 366 367 // Generate .md5 and .sha1 checksum files 368 var artifactFile = artifact.getFile().toPath(); 369 try { 370 MessageDigest md5 = MessageDigest.getInstance("MD5"); 371 MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); 372 try (var fis = Files.newInputStream(artifactFile)) { 373 byte[] buffer = new byte[8192]; 374 while (true) { 375 int read = fis.read(buffer); 376 if (read < 0) { 377 break; 378 } 379 md5.update(buffer, 0, read); 380 sha1.update(buffer, 0, read); 381 } 382 } 383 var fileName = artifactFile.getFileName().toString(); 384 385 // Handle generated md5 386 var md5Path = destinationPath(artifactFile, fileName + ".md5"); 387 Files.writeString(md5Path, toHex(md5.digest())); 388 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.md5", 389 md5Path.toFile()), true)); 390 391 // Handle generated sha1 392 var sha1Path = destinationPath(artifactFile, fileName + ".sha1"); 393 Files.writeString(sha1Path, toHex(sha1.digest())); 394 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.sha1", 395 sha1Path.toFile()), true)); 396 397 // Add signature as yet another artifact 398 var sigPath = signResource(artifactFile); 399 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.asc", 400 sigPath.toFile()), true)); 401 } catch (NoSuchAlgorithmException | IOException | PGPException e) { 402 throw new BuildException(e); 403 } 404 } 405 406 private Path destinationPath(Path base, String fileName) { 407 var dir = artifactDirectory(); 408 if (dir == null) { 409 base.resolveSibling(fileName); 410 } 411 return dir.resolve(fileName); 412 } 413 414 private static String toHex(byte[] bytes) { 415 char[] hexDigits = "0123456789abcdef".toCharArray(); 416 char[] result = new char[bytes.length * 2]; 417 418 for (int i = 0; i < bytes.length; i++) { 419 int unsigned = bytes[i] & 0xFF; 420 result[i * 2] = hexDigits[unsigned >>> 4]; 421 result[i * 2 + 1] = hexDigits[unsigned & 0x0F]; 422 } 423 return new String(result); 424 } 425 426 private void initSigning() 427 throws FileNotFoundException, IOException, PGPException { 428 if (signerBuilder != null) { 429 return; 430 } 431 var keyRingFileName 432 = project().context().property("signing.secretKeyRingFile"); 433 var keyId = project().context().property("signing.keyId"); 434 var passphrase 435 = project().context().property("signing.password").toCharArray(); 436 if (keyRingFileName == null || keyId == null || passphrase == null) { 437 log.warning(() -> "Cannot sign artifacts: properties not set."); 438 return; 439 } 440 Security.addProvider(new BouncyCastleProvider()); 441 var secretKeyRingCollection = new PGPSecretKeyRingCollection( 442 PGPUtil.getDecoderStream( 443 Files.newInputStream(Path.of(keyRingFileName))), 444 new JcaKeyFingerprintCalculator()); 445 var secretKey = secretKeyRingCollection 446 .getSecretKey(Long.parseUnsignedLong(keyId, 16)); 447 publicKey = secretKey.getPublicKey(); 448 privateKey = secretKey.extractPrivateKey( 449 new JcePBESecretKeyDecryptorBuilder().setProvider("BC") 450 .build(passphrase)); 451 signerBuilder = new JcaPGPContentSignerBuilder( 452 publicKey.getAlgorithm(), PGPUtil.SHA256).setProvider("BC"); 453 } 454 455 private Path signResource(Path resource) 456 throws PGPException, IOException { 457 initSigning(); 458 PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator( 459 signerBuilder, publicKey); 460 signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey); 461 var sigPath = destinationPath(resource, 462 resource.getFileName() + ".asc"); 463 try (InputStream fileInput = new BufferedInputStream( 464 Files.newInputStream(resource)); 465 OutputStream sigOut 466 = new ArmoredOutputStream(Files.newOutputStream(sigPath))) { 467 byte[] buffer = new byte[8192]; 468 while (true) { 469 int read = fileInput.read(buffer); 470 if (read < 0) { 471 break; 472 } 473 signatureGenerator.update(buffer, 0, read); 474 } 475 PGPSignature signature = signatureGenerator.generate(); 476 signature.encode(sigOut); 477 } 478 return sigPath; 479 } 480 481 private void deploySnapshot(List<Deployable> toDeploy) 482 throws DeploymentException { 483 // Now deploy everything 484 @SuppressWarnings("PMD.CloseResource") 485 var context = MvnRepoLookup.rootContext(); 486 var session = new DefaultRepositorySystemSession( 487 context.repositorySystemSession()); 488 var startMsgLogged = new AtomicBoolean(false); 489 session.setRepositoryListener(new AbstractRepositoryListener() { 490 @Override 491 public void artifactDeploying(RepositoryEvent event) { 492 if (!startMsgLogged.getAndSet(true)) { 493 log.info(() -> "Start deploying artifacts..."); 494 } 495 } 496 497 @Override 498 public void artifactDeployed(RepositoryEvent event) { 499 if (!"jar".equals(event.getArtifact().getExtension())) { 500 return; 501 } 502 log.info(() -> "Deployed: " + event.getArtifact()); 503 } 504 505 @Override 506 public void metadataDeployed(RepositoryEvent event) { 507 log.info(() -> "Deployed: " + event.getMetadata()); 508 } 509 510 }); 511 var repo = new RemoteRepository.Builder("mine", "default", 512 snapshotUri.toString()) 513 .setAuthentication(new AuthenticationBuilder() 514 .addUsername(repoUser).addPassword(repoPass).build()) 515 .build(); 516 var deployReq = new DeployRequest().setRepository(repo); 517 toDeploy.stream().map(d -> d.artifact).forEach(deployReq::addArtifact); 518 context.repositorySystem().deploy(session, deployReq); 519 } 520 521 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 522 private void deployRelease(Artifact mainArtifact, 523 List<Deployable> toDeploy) { 524 // Create zip file with all artifacts for release, see 525 // https://central.sonatype.org/publish/publish-portal-upload/ 526 var zipName = Optional.ofNullable(project().get(ArtifactId)) 527 .orElse(project().name()) + "-" + mainArtifact.getVersion() 528 + "-release.zip"; 529 var zipPath = artifactDirectory().resolve(zipName); 530 try { 531 Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/')) 532 .resolve(mainArtifact.getArtifactId()) 533 .resolve(mainArtifact.getVersion()); 534 try (ZipOutputStream zos 535 = new ZipOutputStream(Files.newOutputStream(zipPath))) { 536 for (Deployable d : toDeploy) { 537 var artifact = d.artifact(); 538 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 539 var entry = new ZipEntry(praefix.resolve( 540 artifact.getArtifactId() + "-" + artifact.getVersion() 541 + (artifact.getClassifier().isEmpty() 542 ? "" 543 : "-" + artifact.getClassifier()) 544 + "." + artifact.getExtension()) 545 .toString()); 546 zos.putNextEntry(entry); 547 try (var fis = Files.newInputStream( 548 artifact.getFile().toPath())) { 549 fis.transferTo(zos); 550 } 551 zos.closeEntry(); 552 } 553 } 554 } catch (IOException e) { 555 throw new BuildException( 556 "Failed to create release zip: " + e.getMessage(), e); 557 } 558 559 try (var client = HttpClient.newHttpClient()) { 560 var boundary = "===" + System.currentTimeMillis() + "==="; 561 var token = new String(Base64.encode((repoUser + ":" + repoPass) 562 .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); 563 var effectiveUri = uploadUri; 564 if (publishAutomatically) { 565 effectiveUri = addQueryParameter( 566 uploadUri, "publishingType", "AUTOMATIC"); 567 } 568 HttpRequest request = HttpRequest.newBuilder().uri(effectiveUri) 569 .header("Authorization", "Bearer " + token) 570 .header("Content-Type", 571 "multipart/form-data; boundary=" + boundary) 572 .POST(HttpRequest.BodyPublishers 573 .ofInputStream(() -> getAsMultipart(zipPath, boundary))) 574 .build(); 575 log.info(() -> "Uploading release bundle..."); 576 HttpResponse<String> response = client.send(request, 577 HttpResponse.BodyHandlers.ofString()); 578 if (response.statusCode() / 100 != 2) { 579 throw new BuildException( 580 "Failed to upload release bundle: " + response.body()); 581 } 582 } catch (IOException | InterruptedException e) { 583 throw new BuildException( 584 "Failed to upload release bundle: " + e.getMessage(), e); 585 } finally { 586 if (!keepSubArtifacts) { 587 zipPath.toFile().delete(); 588 } 589 } 590 } 591 592 @SuppressWarnings("PMD.UseTryWithResources") 593 private InputStream getAsMultipart(Path zipPath, String boundary) { 594 // Use Piped streams for streaming multipart content 595 var fromPipe = new PipedInputStream(); 596 final String lineFeed = "\r\n"; 597 598 // Write multipart content to pipe 599 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); 600 executor.submit(() -> { 601 try (var mpOut = new BufferedOutputStream( 602 new PipedOutputStream(fromPipe))) { 603 @SuppressWarnings("PMD.InefficientStringBuffering") 604 StringBuilder intro = new StringBuilder(100) 605 .append("--").append(boundary).append(lineFeed) 606 .append("Content-Disposition: form-data; name=\"bundle\";" 607 + " filename=\"%s\"".formatted(zipPath.getFileName())) 608 .append(lineFeed) 609 .append("Content-Type: application/octet-stream") 610 .append(lineFeed).append(lineFeed); 611 mpOut.write( 612 intro.toString().getBytes(StandardCharsets.US_ASCII)); 613 Files.newInputStream(zipPath).transferTo(mpOut); 614 mpOut.write((lineFeed + "--" + boundary + "--") 615 .getBytes(StandardCharsets.US_ASCII)); 616 } catch (IOException e) { 617 throw new UncheckedIOException(e); 618 } finally { 619 executor.close(); 620 } 621 }); 622 return fromPipe; 623 } 624 625 private static URI addQueryParameter(URI uri, String key, String value) { 626 String query = uri.getQuery(); 627 try { 628 String newQueryParam 629 = key + "=" + URLEncoder.encode(value, "UTF-8"); 630 String newQuery = (query == null || query.isEmpty()) ? newQueryParam 631 : query + "&" + newQueryParam; 632 633 // Build a new URI with the new query string 634 return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), 635 newQuery, uri.getFragment()); 636 } catch (UnsupportedEncodingException | URISyntaxException e) { 637 // UnsupportedEncodingException cannot happen, UTF-8 is standard. 638 // URISyntaxException cannot happen when starting with a valid URI 639 throw new IllegalArgumentException(e); 640 } 641 } 642}