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}