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}