001/* 002 * SPDX-License-Identifier: Apache-2.0 003 * 004 * Copyright 2024-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.common.io.resource; 019 020import com.google.common.io.ByteSource; 021import com.google.common.net.MediaType; 022 023import dev.enola.common.FreedesktopDirectories; 024 025import okhttp3.*; 026import okhttp3.logging.HttpLoggingInterceptor; 027 028import org.jspecify.annotations.Nullable; 029import org.slf4j.Logger; 030import org.slf4j.LoggerFactory; 031 032import java.io.*; 033import java.net.URI; 034import java.nio.file.Files; 035import java.time.Duration; 036import java.util.function.Supplier; 037 038/** 039 * Resource implemented with <a href="https://square.github.io/okhttp/">OkHttp</a>. 040 * 041 * <p>Prefer this over {@link UrlResource} (in general). 042 */ 043public class OkHttpResource extends BaseResource implements ReadableResource { 044 045 // TODO Fix potential resource leaks! See details in #byteSource(). 046 047 // TODO Re-design to open 1 instead 2 separate connections for mediaType & byteSource - how?! 048 049 // TODO Better cache failed URLs instead of keep retrying! (If it is? Test...) 050 051 // TODO java.net.http <https://openjdk.org/groups/net/httpclient/intro.html> alternative! 052 053 // TODO https://kong.github.io/unirest-java/ (which uses JDK HttpClient itself?) alternative? 054 055 // TODO https://github.com/mizosoft/methanol as alternative? 056 057 private static final Logger LOG = LoggerFactory.getLogger(OkHttpResource.class); 058 059 // This must be increased if there are test failures on slow CI servers :( 060 private static final Duration t = Duration.ofMillis(7500); 061 062 // https://square.github.io/okhttp/features/caching/ 063 private static final File cacheDir = 064 new File(FreedesktopDirectories.CACHE_FILE, OkHttpResource.class.getSimpleName()); 065 private static final Cache cache = new Cache(cacheDir, 50L * 1024L * 1024L /* 50 MiB */); 066 private static final HttpLoggingInterceptor httpLog = new HttpLoggingInterceptor(); 067 private static final OkHttpClient client = 068 new OkHttpClient.Builder() 069 .cache(cache) 070 .addInterceptor(httpLog) 071 .callTimeout(t) 072 .connectTimeout(t) 073 .readTimeout(t) 074 .writeTimeout(t) 075 .build(); 076 077 static { 078 httpLog.redactHeader("Authorization"); 079 httpLog.redactHeader("Cookie"); 080 httpLog.setLevel(HttpLoggingInterceptor.Level.BASIC); 081 082 try { 083 Files.createDirectories(cacheDir.toPath()); 084 } catch (IOException e) { 085 LOG.warn("Failed to create cache directory: {}", cacheDir.getAbsolutePath(), e); 086 } 087 } 088 089 private static final MediaTypeDetector mtd = new MediaTypeDetector(); 090 091 public static class Provider implements ResourceProvider { 092 @Override 093 public @Nullable Resource getResource(URI uri) { 094 if (uri.getScheme().startsWith("http")) { 095 return new ReadableButNotWritableDelegatingResource(new OkHttpResource(uri)); 096 } else return null; 097 } 098 } 099 100 public OkHttpResource(String url) { 101 super(URI.create(url), () -> mediaType(url)); 102 } 103 104 public OkHttpResource(URI uri) { 105 super(uri, () -> mediaType(uri.toString())); 106 } 107 108 private static Request newRequest(String url) { 109 return new Request.Builder() 110 .url(url) 111 // It's polite to announce who we are... 112 .addHeader("User-Agent", "enola.dev") 113 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept 114 .addHeader("Accept", "*/*") 115 .build(); 116 } 117 118 // See also UrlResource#mediaType(URL url) 119 private static MediaType mediaType(String url) { 120 Request request = newRequest(url); 121 try (var response = client.newCall(request).execute()) { 122 if (!response.isSuccessful()) 123 throw new IllegalArgumentException(unsuccessfulMessage(url, response)); 124 var mt = response.body().contentType(); 125 if (mt != null) { 126 return mtd.overwrite(URI.create(url), okToGuavaMediaType(mt)); 127 } else { 128 throw new IllegalStateException("Success, but no Content-Type header: " + url); 129 } 130 131 } catch (IOException e) { 132 throw new UncheckedIOException("IOException on " + url, e); 133 } 134 } 135 136 private static MediaType okToGuavaMediaType(okhttp3.MediaType okMediaType) { 137 // TODO Optimize? 138 return MediaType.parse(okMediaType.toString()); 139 } 140 141 @Override 142 // TODO Re-design to fix resource leak... as-is, if you call this but then never call 143 // the returned ByteSource's openStream(), then the OkHttp Response will never be closed! :( 144 // This could be fixed by postponing actually opening the connection "down" into openStream(). 145 public ByteSource byteSource() { 146 String url = uri().toString(); 147 Request request = newRequest(url); 148 try { 149 // Intentional not try-with-resource (but that's leaky & NOK; see above) 150 var response = client.newCall(request).execute(); 151 152 // TODO How can this propagate connection errors and timeouts more clearly? 153 if (response.isSuccessful()) 154 return new InputStreamByteSource( 155 () -> response.body().byteStream(), response::close); 156 else return new ErrorByteSource(new IOException(unsuccessfulMessage(url, response))); 157 } catch (IOException e) { 158 return new ErrorByteSource(e); 159 } 160 } 161 162 private static String unsuccessfulMessage(String url, Response response) { 163 return response.code() + " " + url + " : " + response.message(); 164 } 165 166 private static class InputStreamByteSource extends ByteSource { 167 private final Supplier<InputStream> inputStreamSupplier; 168 private final Runnable closer; 169 170 InputStreamByteSource(Supplier<InputStream> inputStreamSupplier, Runnable closer) { 171 this.inputStreamSupplier = inputStreamSupplier; 172 this.closer = closer; 173 } 174 175 @Override 176 public InputStream openStream() throws IOException { 177 return new DelegatingClosingInputStream(inputStreamSupplier.get(), closer); 178 } 179 } 180 181 private static class DelegatingClosingInputStream extends DelegatingInputStream { 182 private final Runnable closer; 183 184 protected DelegatingClosingInputStream(InputStream delegate, Runnable closer) { 185 super(delegate); 186 this.closer = closer; 187 } 188 189 @Override 190 public void close() throws IOException { 191 super.close(); 192 closer.run(); 193 } 194 } 195 196 private static class DelegatingInputStream extends InputStream { 197 198 private final InputStream delegate; 199 200 protected DelegatingInputStream(InputStream delegate) { 201 this.delegate = delegate; 202 } 203 204 @Override 205 public int available() throws IOException { 206 return delegate.available(); 207 } 208 209 @Override 210 public void close() throws IOException { 211 delegate.close(); 212 } 213 214 @Override 215 public void mark(int readlimit) { 216 delegate.mark(readlimit); 217 } 218 219 @Override 220 public boolean markSupported() { 221 return delegate.markSupported(); 222 } 223 224 public static InputStream nullInputStream() { 225 return InputStream.nullInputStream(); 226 } 227 228 public int read() throws IOException { 229 return delegate.read(); 230 } 231 232 @Override 233 public int read(byte[] b) throws IOException { 234 return delegate.read(b); 235 } 236 237 @Override 238 public int read(byte[] b, int off, int len) throws IOException { 239 return delegate.read(b, off, len); 240 } 241 242 @Override 243 public byte[] readAllBytes() throws IOException { 244 return delegate.readAllBytes(); 245 } 246 247 @Override 248 public int readNBytes(byte[] b, int off, int len) throws IOException { 249 return delegate.readNBytes(b, off, len); 250 } 251 252 @Override 253 public byte[] readNBytes(int len) throws IOException { 254 return delegate.readNBytes(len); 255 } 256 257 @Override 258 public void reset() throws IOException { 259 delegate.reset(); 260 } 261 262 @Override 263 public long skip(long n) throws IOException { 264 return delegate.skip(n); 265 } 266 267 @Override 268 public void skipNBytes(long n) throws IOException { 269 delegate.skipNBytes(n); 270 } 271 272 @Override 273 public long transferTo(OutputStream out) throws IOException { 274 return delegate.transferTo(out); 275 } 276 277 @Override 278 public String toString() { 279 return "DelegatingInputStream{" + "delegate=" + delegate + '}'; 280 } 281 } 282}