Skip to content

Commit

Permalink
Provide a way to set fallback file extensions for FileService (#5806)
Browse files Browse the repository at this point in the history
Motivation:

I prefer clean URL patterns without a trailing slash so `/app/projects`
is preferred over `/app/projects/`. If I built `/app/projects` with a
Javascript framework, `/app/projects/index.html` or `/app/projects.html`
may be exported by the framework which is a common feature.

In `FileService`, `/app/projects/index.html` can be served by
`/app/projects/` path, but cannot be found by `/app/projects`. A
trailing slash `/` can be converted into `/index.html` or an auto index
page. As some fallback logics are already implemented, I didn't want to
add a new fallback option for a trailing slash.

Alternatively, I propose an option that appends an extension if there is
no file for the request path. For example, a request sent to
`/app/projects` also finds `/app/projects.[ext]` as a fallback.

Related links:

- #4542
- #1655
- https://ktor.io/docs/server-static-content.html#extensions

Modifications:

- Allow configuring `fallbackFileExtensions()` via `FileServiceBuilder`
- Find a file with fallback extensions if missing.

Result:

- You can now set fallback file extensions to look up files in
`FileService`.

```java
FileService
  .builder(rootDir)
  .fallbackFileExtensions("html", "txt")
  ...
```
- Closes #4542
  • Loading branch information
ikhoon committed Jul 16, 2024
1 parent 352494c commit c208353
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
Expand Down Expand Up @@ -257,35 +258,18 @@ private HttpFile findFile(ServiceRequestContext ctx, HttpRequest req) {
});
});
} else {
// Redirect to the slash appended path if:
// 1) /index.html exists or
// 2) it has a directory listing.
final String indexPath = decodedMappedPath + "/index.html";
return findFile(ctx, indexPath, encodings, decompress).thenCompose(indexFile -> {
if (indexFile != null) {
return UnmodifiableFuture.completedFuture(true);
}
final List<String> fallbackExtensions = config.fallbackFileExtensions();
if (fallbackExtensions.isEmpty()) {
return findFileWithIndexPath(ctx, decodedMappedPath, encodings, decompress);
}

if (!config.autoIndex()) {
return UnmodifiableFuture.completedFuture(false);
}

return config.vfs().canList(ctx.blockingTaskExecutor(), decodedMappedPath);
}).thenApply(canList -> {
if (canList) {
try (TemporaryThreadLocals ttl = TemporaryThreadLocals.acquire()) {
final StringBuilder locationBuilder = ttl.stringBuilder()
.append(ctx.path())
.append('/');
if (ctx.query() != null) {
locationBuilder.append('?')
.append(ctx.query());
}
return HttpFile.ofRedirect(locationBuilder.toString());
}
} else {
return HttpFile.nonExistent();
// Try appending file extensions if it was a file access and file extensions are configured.
return findFileWithExtensions(ctx, fallbackExtensions.iterator(), decodedMappedPath,
encodings, decompress).thenCompose(fileWithExtension -> {
if (fileWithExtension != null) {
return UnmodifiableFuture.completedFuture(fileWithExtension);
}
return findFileWithIndexPath(ctx, decodedMappedPath, encodings, decompress);
});
}
}));
Expand Down Expand Up @@ -385,6 +369,58 @@ private HttpFile findFile(ServiceRequestContext ctx, HttpRequest req) {
});
}

private CompletableFuture<@Nullable HttpFile> findFileWithIndexPath(
ServiceRequestContext ctx, String decodedMappedPath,
Set<ContentEncoding> encodings, boolean decompress) {
// Redirect to the slash appended path if:
// 1) /index.html exists or
// 2) it has a directory listing.
final String indexPath = decodedMappedPath + "/index.html";
return findFile(ctx, indexPath, encodings, decompress).thenCompose(indexFile -> {
if (indexFile != null) {
return UnmodifiableFuture.completedFuture(true);
}

if (!config.autoIndex()) {
return UnmodifiableFuture.completedFuture(false);
}

return config.vfs().canList(ctx.blockingTaskExecutor(), decodedMappedPath);
}).thenApply(canList -> {
if (canList) {
try (TemporaryThreadLocals ttl = TemporaryThreadLocals.acquire()) {
final StringBuilder locationBuilder = ttl.stringBuilder()
.append(ctx.path())
.append('/');
if (ctx.query() != null) {
locationBuilder.append('?')
.append(ctx.query());
}
return HttpFile.ofRedirect(locationBuilder.toString());
}
} else {
return HttpFile.nonExistent();
}
});
}

private CompletableFuture<@Nullable HttpFile> findFileWithExtensions(
ServiceRequestContext ctx, @Nullable Iterator<String> extensionIterator, String path,
Set<ContentEncoding> supportedEncodings, boolean decompress) {
if (extensionIterator == null || !extensionIterator.hasNext()) {
return UnmodifiableFuture.completedFuture(null);
}

final String extension = extensionIterator.next();
return findFile(ctx, path + '.' + extension, supportedEncodings, decompress).thenCompose(file -> {
if (file != null) {
return UnmodifiableFuture.completedFuture(file);
}

return findFileWithExtensions(ctx, extensionIterator, path, supportedEncodings, decompress);
});
}

private CompletableFuture<@Nullable HttpFile> findFileAndDecompress(
ServiceRequestContext ctx, String path, Set<ContentEncoding> supportedEncodings) {
// Look up a non-compressed file first to avoid extra decompression
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@

package com.linecorp.armeria.server.file;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.linecorp.armeria.server.file.FileServiceConfig.validateEntryCacheSpec;
import static com.linecorp.armeria.server.file.FileServiceConfig.validateMaxCacheEntrySizeBytes;
import static com.linecorp.armeria.server.file.FileServiceConfig.validateNonNegativeParameter;
import static java.util.Objects.requireNonNull;

import java.time.Clock;
import java.util.List;
import java.util.Map.Entry;

import com.github.benmanes.caffeine.cache.CaffeineSpec;
import com.google.common.collect.ImmutableList;

import com.linecorp.armeria.common.CacheControl;
import com.linecorp.armeria.common.Flags;
Expand All @@ -35,6 +38,7 @@
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.MediaType;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.annotation.UnstableApi;

/**
* Builds a new {@link FileService} and its {@link FileServiceConfig}. Use the factory methods in
Expand All @@ -60,6 +64,9 @@ public final class FileServiceBuilder {
HttpHeadersBuilder headers;
MediaTypeResolver mediaTypeResolver = MediaTypeResolver.ofDefault();

@Nullable
private ImmutableList.Builder<String> fallbackFileExtensions;

FileServiceBuilder(HttpVfs vfs) {
this.vfs = requireNonNull(vfs, "vfs");
}
Expand Down Expand Up @@ -153,6 +160,46 @@ public FileServiceBuilder autoIndex(boolean autoIndex) {
return this;
}

/**
* Adds the file extensions to be considered when resolving file names.
* This method allows specifying alternative file names by appending the provided extensions
* to the requested file name if the initially requested resource is not found.
*
* <p>For instance, if {@code "/index"} is requested and {@code "html"} is an added extension,
* {@link FileService} will attempt to serve {@code "/index.html"} if {@code "/index"} is not found.
*/
@UnstableApi
public FileServiceBuilder fallbackFileExtensions(String... extensions) {
requireNonNull(extensions, "extensions");
return fallbackFileExtensions(ImmutableList.copyOf(extensions));
}

/**
* Adds the file extensions to be considered when resolving file names.
* This method allows specifying alternative file names by appending the provided extensions
* to the requested file name if the initially requested resource is not found.
*
* <p>For instance, if {@code "/index"} is requested and {@code "html"} is an added extension,
* {@link FileService} will attempt to serve {@code "/index.html"} if {@code "/index"} is not found.
*/
@UnstableApi
public FileServiceBuilder fallbackFileExtensions(Iterable<String> extensions) {
requireNonNull(extensions, "extensions");
for (String extension : extensions) {
checkArgument(!extension.isEmpty(), "extension is empty");
checkArgument(extension.charAt(0) != '.', "extension: %s (expected: without a dot)", extension);
}
if (fallbackFileExtensions == null) {
fallbackFileExtensions = ImmutableList.builder();
}
fallbackFileExtensions.addAll(extensions);
return this;
}

private List<String> fallbackFileExtensions() {
return fallbackFileExtensions != null ? fallbackFileExtensions.build() : ImmutableList.of();
}

/**
* Returns the immutable additional {@link HttpHeaders} which will be set when building an
* {@link HttpResponse}.
Expand Down Expand Up @@ -248,12 +295,13 @@ public FileService build() {
return new FileService(new FileServiceConfig(
vfs, clock, entryCacheSpec, maxCacheEntrySizeBytes,
serveCompressedFiles, autoDecompress, autoIndex, buildHeaders(),
mediaTypeResolver.orElse(MediaTypeResolver.ofDefault())));
mediaTypeResolver.orElse(MediaTypeResolver.ofDefault()), fallbackFileExtensions()));
}

@Override
public String toString() {
return FileServiceConfig.toString(this, vfs, clock, entryCacheSpec, maxCacheEntrySizeBytes,
serveCompressedFiles, autoIndex, headers, mediaTypeResolver);
serveCompressedFiles, autoIndex, headers, mediaTypeResolver,
fallbackFileExtensions());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import static java.util.Objects.requireNonNull;

import java.time.Clock;
import java.util.List;
import java.util.Map.Entry;

import com.github.benmanes.caffeine.cache.CaffeineSpec;
Expand All @@ -28,6 +29,7 @@
import com.linecorp.armeria.common.HttpHeaders;
import com.linecorp.armeria.common.MediaType;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.annotation.UnstableApi;

import io.netty.util.AsciiString;

Expand All @@ -46,10 +48,12 @@ public final class FileServiceConfig {
private final boolean autoIndex;
private final HttpHeaders headers;
private final MediaTypeResolver mediaTypeResolver;
private final List<String> fallbackFileExtensions;

FileServiceConfig(HttpVfs vfs, Clock clock, @Nullable String entryCacheSpec, int maxCacheEntrySizeBytes,
boolean serveCompressedFiles, boolean autoDecompress, boolean autoIndex,
HttpHeaders headers, MediaTypeResolver mediaTypeResolver) {
HttpHeaders headers, MediaTypeResolver mediaTypeResolver,
List<String> fallbackFileExtensions) {
this.vfs = requireNonNull(vfs, "vfs");
this.clock = requireNonNull(clock, "clock");
this.entryCacheSpec = validateEntryCacheSpec(entryCacheSpec);
Expand All @@ -59,6 +63,7 @@ public final class FileServiceConfig {
this.autoIndex = autoIndex;
this.headers = requireNonNull(headers, "headers");
this.mediaTypeResolver = requireNonNull(mediaTypeResolver, "mediaTypeResolver");
this.fallbackFileExtensions = requireNonNull(fallbackFileExtensions, "fallbackFileExtensions");
}

@Nullable
Expand Down Expand Up @@ -152,17 +157,26 @@ public MediaTypeResolver mediaTypeResolver() {
return mediaTypeResolver;
}

/**
* Returns the file extensions that are appended to the file name when the file is not found.
*/
@UnstableApi
public List<String> fallbackFileExtensions() {
return fallbackFileExtensions;
}

@Override
public String toString() {
return toString(this, vfs(), clock(), entryCacheSpec(), maxCacheEntrySizeBytes(),
serveCompressedFiles(), autoIndex(), headers(), mediaTypeResolver());
serveCompressedFiles(), autoIndex(), headers(), mediaTypeResolver(),
fallbackFileExtensions());
}

static String toString(Object holder, HttpVfs vfs, Clock clock,
@Nullable String entryCacheSpec, int maxCacheEntrySizeBytes,
boolean serveCompressedFiles, boolean autoIndex,
@Nullable Iterable<Entry<AsciiString, String>> headers,
MediaTypeResolver mediaTypeResolver) {
MediaTypeResolver mediaTypeResolver, @Nullable List<String> fallbackFileExtensions) {

return MoreObjects.toStringHelper(holder).omitNullValues()
.add("vfs", vfs)
Expand All @@ -173,6 +187,7 @@ static String toString(Object holder, HttpVfs vfs, Clock clock,
.add("autoIndex", autoIndex)
.add("headers", headers)
.add("mediaTypeResolver", mediaTypeResolver)
.add("fallbackFileExtensions", fallbackFileExtensions)
.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.io.TempDir;
Expand All @@ -54,8 +55,10 @@
import com.google.common.io.ByteStreams;
import com.google.common.io.Resources;

import com.linecorp.armeria.client.BlockingWebClient;
import com.linecorp.armeria.client.WebClient;
import com.linecorp.armeria.common.AggregatedHttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.MediaType;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.util.OsType;
Expand Down Expand Up @@ -173,6 +176,23 @@ protected void configure(ServerBuilder sb) {
.maxCacheEntries(0)
.build()));

sb.serviceUnder(
"/no-extension",
FileService.builder(classLoader, baseResourceDir + "foo")
.build());
sb.serviceUnder(
"/extension",
FileService.builder(classLoader, baseResourceDir + "foo")
.fallbackFileExtensions("txt")
.build());
sb.serviceUnder(
"/extension/decompress",
FileService.builder(classLoader, baseResourceDir + "foo")
.fallbackFileExtensions("txt")
.serveCompressedFiles(true)
.autoDecompress(true)
.build());

sb.decorator(LoggingService.newDecorator());
}
};
Expand Down Expand Up @@ -625,6 +645,33 @@ void testFileSystemGetUtf8(String baseUri) throws Exception {
}
}

@Test
void useFileExtensionsToFindFile() {
final BlockingWebClient client = server.blockingWebClient();
AggregatedHttpResponse response = client.get("/extension/foo.txt");
assertThat(response.status()).isEqualTo(HttpStatus.OK);
assertThat(response.contentUtf8()).isEqualTo("foo");

// Without .txt extension
response = client.get("/extension/foo");
assertThat(response.status()).isEqualTo(HttpStatus.OK);
assertThat(response.contentUtf8()).isEqualTo("foo");
// Make sure that the existing operation is not affected by the fileExtensions option.
response = client.get("/extension/");
assertThat(response.status()).isEqualTo(HttpStatus.OK);
assertThat(response.contentUtf8()).isEqualTo("<html><body></body></html>\n");

response = client.get("/no-extension/foo.txt");
assertThat(response.status()).isEqualTo(HttpStatus.OK);
assertThat(response.contentUtf8()).isEqualTo("foo");
response = client.get("/no-extension/foo");
assertThat(response.status()).isEqualTo(HttpStatus.NOT_FOUND);

response = client.get("/extension/decompress/foo");
assertThat(response.status()).isEqualTo(HttpStatus.OK);
assertThat(response.contentUtf8()).isEqualTo("foo");
}

private static void writeFile(Path path, String content) throws Exception {
// Retry to work around the `AccessDeniedException` in Windows.
for (int i = 9; i >= 0; i--) {
Expand Down

0 comments on commit c208353

Please sign in to comment.