From fc703730527e8829f88ea35dc2abcbe8823768a2 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Mon, 25 Nov 2024 20:59:47 +0100 Subject: [PATCH] 4.x: Update to static content (#9502) * Static content update - WebServer feature to support static content from configuration - new Builder API based prototypes to configure it - deprecated old approach * Use defaults for sockets and welcome file. Added singular methods for classpath and path handlers. Renamed welcome file to welcome to align with current MP configuration option. * CDI extension update to use new API with same defaults, update to deprecated usages to avoid compiler warnings. * Documentation update for MP and SE. * Fix classpath root cleanup. Fix configuration used in MP extension. * Introduction of io.helidon.common.Size to handle size strings (similar to Java's Duration, but for size in bytes). Update to builder to support nice defaults for size. Update to PR to fix comments, and use Size for memory cache capacity. --- .../helidon/builder/codegen/TypeHandler.java | 9 +- .../io/helidon/codegen/CodegenValidator.java | 32 ++ .../helidon/codegen/TypeInfoFactoryBase.java | 10 +- .../src/main/java/io/helidon/common/Size.java | 352 ++++++++++++++++++ .../main/java/io/helidon/common/SizeImpl.java | 111 ++++++ .../test/java/io/helidon/common/SizeTest.java | 160 ++++++++ .../io/helidon/common/types/TypeNames.java | 5 + docs-internal/http-features.md | 1 + docs/src/main/asciidoc/mp/server.adoc | 33 +- docs/src/main/asciidoc/se/webserver.adoc | 48 ++- .../io/helidon/docs/se/WebServerSnippets.java | 16 +- .../server/ServerCdiExtension.java | 48 ++- .../META-INF/microprofile-config.properties | 6 +- .../static-content/etc/spotbugs/exclude.xml | 16 +- webserver/static-content/pom.xml | 47 +++ .../BaseHandlerConfigBlueprint.java | 137 +++++++ .../staticcontent/CachedHandlerInMemory.java | 4 +- .../staticcontent/CachedHandlerJar.java | 108 +++++- .../ClassPathContentHandler.java | 241 ++++++------ .../ClasspathHandlerConfigBlueprint.java | 54 +++ .../FileBasedContentHandler.java | 10 +- .../FileSystemContentHandler.java | 21 +- .../FileSystemHandlerConfigBlueprint.java | 38 ++ .../webserver/staticcontent/IoSupplier.java | 24 ++ .../webserver/staticcontent/MemoryCache.java | 180 +++++++++ .../MemoryCacheConfigBlueprint.java | 53 +++ .../SingleFileContentHandler.java | 10 +- .../StaticContentConfigBlueprint.java | 154 ++++++++ .../StaticContentConfigSupport.java | 93 +++++ .../staticcontent/StaticContentFeature.java | 245 ++++++++++++ .../StaticContentFeatureProvider.java | 49 +++ .../staticcontent/StaticContentHandler.java | 67 ++-- .../staticcontent/StaticContentService.java | 95 +++-- .../staticcontent/TemporaryStorage.java | 74 ++++ .../TemporaryStorageConfigBlueprint.java | 86 +++++ .../staticcontent/TemporaryStorageImpl.java | 118 ++++++ .../src/main/java/module-info.java | 6 +- .../staticcontent/CachedHandlerTest.java | 3 +- .../StaticContentConfigTest.java | 171 +++++++++ .../StaticContentHandlerTest.java | 16 +- .../staticcontent/StaticContentTest.java | 82 +++- .../test/resources/config-unit-test-1.yaml | 42 +++ .../test/resources/logging-test.properties | 19 + 43 files changed, 2827 insertions(+), 267 deletions(-) create mode 100644 common/common/src/main/java/io/helidon/common/Size.java create mode 100644 common/common/src/main/java/io/helidon/common/SizeImpl.java create mode 100644 common/common/src/test/java/io/helidon/common/SizeTest.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/BaseHandlerConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClasspathHandlerConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemHandlerConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/IoSupplier.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCache.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCacheConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigSupport.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeature.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeatureProvider.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorage.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageImpl.java create mode 100644 webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentConfigTest.java create mode 100644 webserver/static-content/src/test/resources/config-unit-test-1.yaml create mode 100644 webserver/static-content/src/test/resources/logging-test.properties diff --git a/builder/codegen/src/main/java/io/helidon/builder/codegen/TypeHandler.java b/builder/codegen/src/main/java/io/helidon/builder/codegen/TypeHandler.java index 4acd5b5975d..479db076ad3 100644 --- a/builder/codegen/src/main/java/io/helidon/builder/codegen/TypeHandler.java +++ b/builder/codegen/src/main/java/io/helidon/builder/codegen/TypeHandler.java @@ -32,6 +32,7 @@ import io.helidon.codegen.classmodel.InnerClass; import io.helidon.codegen.classmodel.Javadoc; import io.helidon.codegen.classmodel.Method; +import io.helidon.common.Size; import io.helidon.common.types.AccessModifier; import io.helidon.common.types.TypeName; import io.helidon.common.types.TypeNames; @@ -178,8 +179,14 @@ Consumer> toDefaultValue(String defaultValue) { .addContent(defaultValue) .addContent("\""); } + if (TypeNames.SIZE.equals(typeName)) { + CodegenValidator.validateSize(enclosingType, annotatedMethod, OPTION_DEFAULT, "value", defaultValue); + return content -> content.addContent(Size.class) + .addContent(".parse(\"") + .addContent(defaultValue) + .addContent("\")"); + } if (TypeNames.DURATION.equals(typeName)) { - CodegenValidator.validateDuration(enclosingType, annotatedMethod, OPTION_DEFAULT, "value", defaultValue); return content -> content.addContent(Duration.class) .addContent(".parse(\"") diff --git a/codegen/codegen/src/main/java/io/helidon/codegen/CodegenValidator.java b/codegen/codegen/src/main/java/io/helidon/codegen/CodegenValidator.java index ff3d21ebb2d..b6aeb6c1b9a 100644 --- a/codegen/codegen/src/main/java/io/helidon/codegen/CodegenValidator.java +++ b/codegen/codegen/src/main/java/io/helidon/codegen/CodegenValidator.java @@ -19,6 +19,7 @@ import java.net.URI; import java.time.Duration; +import io.helidon.common.Size; import io.helidon.common.types.TypeName; import io.helidon.common.types.TypedElementInfo; @@ -88,4 +89,35 @@ public static String validateDuration(TypeName enclosingType, + element.elementName())); } } + + /** + * Validate a {@link io.helidon.common.Size} annotation on a method, field, or constructor. + * + * @param enclosingType type that owns the element + * @param element annotated element + * @param annotationType type of annotation + * @param property property of annotation + * @param value actual value read from the annotation property + * @return the value + * @throws io.helidon.codegen.CodegenException with correct source element describing the problem + */ + public static String validateSize(TypeName enclosingType, + TypedElementInfo element, + TypeName annotationType, + String property, + String value) { + try { + Size.parse(value); + return value; + } catch (Exception e) { + throw new CodegenException("Size expression of annotation " + annotationType.fqName() + "." + + property + "(): " + + "\"" + value + "\" cannot be parsed. Size expects an" + + " expression such as '120 KB' (120 * 1024 * 1024), " + + "'120 kB' (120 * 1000 * 1000), or '120 KiB' (same as KB)" + + " Please check javadoc of " + Size.class.getName() + " class.", + e, + element.originatingElementValue()); + } + } } diff --git a/codegen/codegen/src/main/java/io/helidon/codegen/TypeInfoFactoryBase.java b/codegen/codegen/src/main/java/io/helidon/codegen/TypeInfoFactoryBase.java index e0a233f1d7d..79c0a7867eb 100644 --- a/codegen/codegen/src/main/java/io/helidon/codegen/TypeInfoFactoryBase.java +++ b/codegen/codegen/src/main/java/io/helidon/codegen/TypeInfoFactoryBase.java @@ -44,6 +44,7 @@ public abstract class TypeInfoFactoryBase { TypeName.create(Target.class), TypeName.create(Retention.class), TypeName.create(Repeatable.class)); + private static final Set ACCESS_MODIFIERS = Set.of("PUBLIC", "PRIVATE", "PROTECTED"); /** * There are no side effects of this constructor. @@ -144,10 +145,15 @@ protected static Set modifiers(CodegenContext Set result = new HashSet<>(); for (String stringModifier : stringModifiers) { + String upperCased = stringModifier.toUpperCase(Locale.ROOT); + if (ACCESS_MODIFIERS.contains(upperCased)) { + // ignore access modifiers, as they are handled elsewhere + continue; + } try { - result.add(io.helidon.common.types.Modifier.valueOf(stringModifier.toUpperCase(Locale.ROOT))); + result.add(io.helidon.common.types.Modifier.valueOf(upperCased)); } catch (Exception ignored) { - // we do not care about modifiers we do not understand - either access modifier, or something new + // we do not care about modifiers we do not understand ctx.logger().log(System.Logger.Level.TRACE, "Modifier " + stringModifier + " not understood by type info factory."); } diff --git a/common/common/src/main/java/io/helidon/common/Size.java b/common/common/src/main/java/io/helidon/common/Size.java new file mode 100644 index 00000000000..b888d8b4e09 --- /dev/null +++ b/common/common/src/main/java/io/helidon/common/Size.java @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.common; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * A definition of size in bytes. + */ +public interface Size { + /** + * Empty size - zero bytes. + */ + Size ZERO = Size.create(0); + + /** + * Create a new size with explicit number of bytes. + * + * @param size number of bytes + * @return a new size instance + */ + static Size create(long size) { + return new SizeImpl(BigInteger.valueOf(size)); + } + + /** + * Create a new size from amount and unit. + * + * @param amount amount in the provided unit + * @param unit unit + * @return size representing the amount + */ + static Size create(long amount, Unit unit) { + Objects.requireNonNull(unit, "Unit must not be null"); + + return new SizeImpl(BigInteger.valueOf(amount).multiply(unit.bytesInteger())); + } + + /** + * Create a new size from amount and unit. + * + * @param amount amount that can be decimal + * @param unit unit + * @return size representing the amount + * @throws IllegalArgumentException in case the amount cannot be converted to whole bytes (i.e. it has + * a fraction of byte) + */ + static Size create(BigDecimal amount, Unit unit) { + Objects.requireNonNull(amount, "Amount must not be null"); + Objects.requireNonNull(unit, "Unit must not be null"); + + BigDecimal result = amount.multiply(new BigDecimal(unit.bytesInteger())); + return new SizeImpl(result.toBigIntegerExact()); + } + + /** + * Crete a new size from the size string. + * The string may contain a unit. If a unit is not present, the size string is considered to be number of bytes. + *

+ * We understand units from kilo (meaning 1000 or 1024, see table below), to exa bytes. + * Each higher unit is either 1000 times or 1024 times bigger than the one below, depending on the approach used. + *

+ * Measuring approaches and their string representations: + *

    + *
  • KB, KiB - kibi, kilobinary, stands for 1024 bytes
  • + *
  • kB, kb - kilobyte, stands for 1000 bytes
  • + *
  • MB, MiB - mebi, megabinary, stands for 1024*1024 bytes
  • + *
  • mB, mb - megabyte, stands for 1000*1000 bytes
  • + *
  • From here the same concept is applied with Giga, Tera, Peta, and Exa bytes
  • + *
+ * + * @param sizeString the string definition, such as {@code 76 MB}, or {@code 976 mB}, can also be a decimal number + * - we use {@link java.math.BigDecimal} to parse the numeric section of the size; if there is a unit + * defined, it must be separated by a single space from the numeric section + * @return parsed size that can provide exact number of bytes + */ + static Size parse(String sizeString) { + Objects.requireNonNull(sizeString, "Size string is null"); + + String parsed = sizeString.trim(); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("Size string is empty."); + } + int lastSpace = parsed.lastIndexOf(' '); + if (lastSpace == -1) { + // no unit + return create(new BigDecimal(parsed), Unit.BYTE); + } + String size = parsed.substring(0, lastSpace); + Unit unit = Unit.parse(parsed.substring(lastSpace + 1)); + BigDecimal amount = new BigDecimal(size); + return create(amount, unit); + } + + /** + * Amount of units in this size. + * + * @param unit to get the size of + * @return size in the provided unit as a big decimal + * @throws ArithmeticException in case this size cannot be converted to the specified unit without losing + * information + * @see #toBytes() + */ + BigDecimal to(Unit unit); + + /** + * Number of bytes this size represents. + * + * @return number of bytes + * @throws ArithmeticException in case the amount is higher than {@link Long#MAX_VALUE}, or would contain + * fractions of byte + */ + long toBytes(); + + /** + * Get the highest possible unit of the size with integer amount. + * + * @param unitKind kind of unit to print (kB, kb, KB, or KiB) + * @return amount integer with a unit, such as {@code 270 kB}, if the amount is {@code 2000 kB}, this method would return + * {@code 2 mB} instead for {@link io.helidon.common.Size.UnitKind#DECIMAL_UPPER_CASE} + */ + String toString(UnitKind unitKind); + + /** + * Get the amount in the provided unit as a decimal number if needed. If the amount cannot be correctly + * expressed in the provided unit, an exception is thrown. + * + * @param unit unit to use, such as {@link io.helidon.common.Size.Unit#MIB} + * @param unitKind kind of unit for the output, must match the provided unit, + * such as {@link io.helidon.common.Size.UnitKind#BINARY_BI} to print {@code MiB} + * @return amount decimal with a unit, such as {@code 270.426 MiB} + * @throws java.lang.IllegalArgumentException in case the unitKind does not match the unit + */ + String toString(Unit unit, UnitKind unitKind); + + /** + * Kind of units, used for printing out the correct unit. + */ + enum UnitKind { + /** + * The first letter (if two lettered) is lower case, the second is upper case, such ase + * {@code B, kB, mB}. These represent powers of 1000. + */ + DECIMAL_UPPER_CASE(false), + /** + * All letters are lower case, such as + * {@code b, kb, mb}. These represent powers of 1000. + */ + DECIMAL_LOWER_CASE(false), + /** + * The multiplier always contains {@code i}, the second is upper case B, such ase + * {@code B, KiB, MiB}. These represent powers of 1024. + */ + BINARY_BI(true), + /** + * All letters are upper case, such as + * {@code B, KB, MB}. These represent powers of 1024. + */ + BINARY_UPPER_CASE(true); + private final boolean isBinary; + + UnitKind(boolean isBinary) { + this.isBinary = isBinary; + } + + boolean isBinary() { + return isBinary; + } + } + + /** + * Units that can be used. + */ + enum Unit { + /** + * Bytes. + */ + BYTE(1024, 0, "b", "B"), + /** + * Kilobytes (represented as {@code kB}), where {@code kilo} is used in its original meaning as a thousand, + * i.e. 1 kB is 1000 bytes. + */ + KB(1000, 1, "kB", "kb"), + /** + * Kibi-bytes (represented as either {@code KB} or {@code KiB}), where we use binary approach, i.e. + * 1 KB or KiB is 1024 bytes. + */ + KIB(1024, 1, "KB", "KiB"), + /** + * Megabytes (represented as {@code mB}), where {@code mega} is used in its original meaning as a million, + * i.e. 1 mB is 1000^2 bytes (1000 to the power of 2), or 1000 kB. + */ + MB(1000, 2, "mB", "mb"), + /** + * Mebi-bytes (represented as either {@code MB} or {@code MiB}), where we use binary approach, i.e. + * 1 MB or MiB is 1024^2 bytes (1024 to the power 2), or 1024 KiB. + */ + MIB(1024, 2, "MB", "MiB"), + /** + * Gigabytes (represented as {@code gB}): + * i.e. 1 gB is 1000^3 bytes (1000 to the power of 3), or 1000 mB. + */ + GB(1000, 3, "gB", "gb"), + /** + * Gibi-bytes (represented as either {@code GB} or {@code GiB}), where we use binary approach, i.e. + * 1 GB or GiB is 1024^3 bytes (1024 to the power 3), or 1024 MiB. + */ + GIB(1024, 3, "GB", "GiB"), + /** + * Terabytes (represented as {@code tB}): + * i.e. 1 gB is 1000^4 bytes (1000 to the power of 4), or 1000 gB. + */ + TB(1000, 4, "tB", "tb"), + /** + * Tebi-bytes (represented as either {@code TB} or {@code TiB}), where we use binary approach, i.e. + * 1 TB or TiB is 1024^4 bytes (1024 to the power 4), or 1024 GiB. + */ + TIB(1024, 4, "TB", "TiB"), + /** + * Petabytes (represented as {@code pB}): + * i.e. 1 pB is 1000^5 bytes (1000 to the power of 5), or 1000 tB. + */ + PB(1000, 5, "pB", "pb"), + /** + * Pebi-bytes (represented as either {@code PB} or {@code PiB}), where we use binary approach, i.e. + * 1 PB or PiB is 1024^5 bytes (1024 to the power 5), or 1024 TiB. + */ + PIB(1024, 5, "PB", "PiB"), + /** + * Exabytes (represented as {@code eB}): + * i.e. 1 eB is 1000^6 bytes (1000 to the power of 6), or 1000 pB. + */ + EB(1000, 6, "eB", "eb"), + /** + * Exbi-bytes (represented as either {@code EB} or {@code EiB}), where we use binary approach, i.e. + * 1 EB or EiB is 1024^6 bytes (1024 to the power 6), or 1024 PiB. + */ + EIB(1024, 6, "EB", "EiB"); + + private static final Map UNIT_MAP; + + static { + Map units = new HashMap<>(); + for (Unit unit : Unit.values()) { + for (String validUnitString : unit.units) { + units.put(validUnitString, unit); + } + } + UNIT_MAP = Map.copyOf(units); + } + + private final long bytes; + private final int power; + private final BigInteger bytesInteger; + private final Set units; + private final boolean binary; + private final String firstUnit; + private final String secondUnit; + + /** + * Unit. + * + * @param base base of the calculation (1000 or 1024) + * @param power to the power of + * @param firstUnit first unit (either upper case decimal [mB], or all upper case [MB]) + * @param secondUnit second unit (either lower case decimal [mb], or binary unit name [MiB]) + */ + Unit(int base, int power, String firstUnit, String secondUnit) { + this.firstUnit = firstUnit; + this.secondUnit = secondUnit; + this.units = Set.of(firstUnit, secondUnit); + this.bytes = (long) Math.pow(base, power); + this.bytesInteger = BigInteger.valueOf(bytes); + this.power = power; + this.binary = base == 1024; + } + + /** + * Parse the size string to appropriate unit. + * + * @param unitString defines the unit, such as {@code KB}, {@code MiB}, {@code pB} etc.; empty string parses to + * {@link #BYTE} + * @return a parsed unit + * @throws IllegalArgumentException if the unit cannot be parsed + */ + public static Unit parse(String unitString) { + if (unitString.isEmpty()) { + return BYTE; + } + Unit unit = UNIT_MAP.get(unitString); + if (unit == null) { + throw new IllegalArgumentException("Unknown unit: " + unitString); + } + return unit; + } + + /** + * Number of bytes this unit represents. + * + * @return number of bytes of this unit + */ + public long bytes() { + return bytes; + } + + /** + * Number of bytes in this unit (exact integer). + * + * @return number of bytes this unit contains + */ + public BigInteger bytesInteger() { + return bytesInteger; + } + + String unitString(UnitKind unitKind) { + if (power == 0) { + if (unitKind == UnitKind.DECIMAL_LOWER_CASE) { + return "b"; + } + return "B"; + } + + return switch (unitKind) { + case DECIMAL_UPPER_CASE, BINARY_UPPER_CASE -> firstUnit; + case DECIMAL_LOWER_CASE, BINARY_BI -> secondUnit; + }; + } + + boolean isBinary() { + return binary; + } + } +} diff --git a/common/common/src/main/java/io/helidon/common/SizeImpl.java b/common/common/src/main/java/io/helidon/common/SizeImpl.java new file mode 100644 index 00000000000..e89b2bcbc0e --- /dev/null +++ b/common/common/src/main/java/io/helidon/common/SizeImpl.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.common; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.util.Objects; + +class SizeImpl implements Size { + private final BigInteger bytes; + + SizeImpl(BigInteger bytes) { + this.bytes = bytes; + } + + @Override + public BigDecimal to(Unit unit) { + Objects.requireNonNull(unit, "Unit must not be null"); + + BigDecimal bigDecimal = new BigDecimal(unit.bytesInteger()); + BigDecimal result = new BigDecimal(bytes).divide(bigDecimal, + bigDecimal.precision() + 1, + RoundingMode.UNNECESSARY); + return result.stripTrailingZeros(); + } + + @Override + public long toBytes() { + try { + return bytes.longValueExact(); + } catch (ArithmeticException e) { + // we cannot use a cause with constructor, creating a more descriptive message + throw new ArithmeticException("Size " + this + " cannot be converted to number of bytes, out of long range."); + } + } + + @Override + public String toString(UnitKind unitKind) { + Objects.requireNonNull(unitKind, "Unit kind must not be null"); + + if (bytes.equals(BigInteger.ZERO)) { + return "0 " + Unit.BYTE.unitString(unitKind); + } + + // try each amount from the highest that returns zero decimal places + Unit[] values = Unit.values(); + for (int i = values.length - 1; i >= 0; i--) { + Unit value = values[i]; + if (value.isBinary() != unitKind.isBinary()) { + continue; + } + BigDecimal bigDecimal = to(value); + try { + // try to convert without any decimal spaces + BigInteger bi = bigDecimal.toBigIntegerExact(); + return bi + " " + value.unitString(unitKind); + } catch (Exception ignored) { + // ignored, we cannot convert to this unit, because it cannot be correctly divided + } + } + + return bytes + " " + Unit.BYTE.unitString(unitKind); + } + + @Override + public String toString(Unit unit, UnitKind unitKind) { + Objects.requireNonNull(unit, "Unit must not be null"); + Objects.requireNonNull(unitKind, "Unit kind must not be null"); + + if (unit.isBinary() != unitKind.isBinary()) { + throw new IllegalArgumentException("Unit " + unit + " does not match kind " + unitKind); + } + String unitString = unit.unitString(unitKind); + BigDecimal amount = to(unit); + + return amount.toPlainString() + " " + unitString; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Size size)) { + return false; + } + return Objects.equals(size.to(Unit.BYTE), this.to(Unit.BYTE)); + } + + @Override + public int hashCode() { + return Objects.hash(to(Unit.BYTE)); + } + + @Override + public String toString() { + return toString(UnitKind.DECIMAL_UPPER_CASE); + } +} diff --git a/common/common/src/test/java/io/helidon/common/SizeTest.java b/common/common/src/test/java/io/helidon/common/SizeTest.java new file mode 100644 index 00000000000..73be10f677b --- /dev/null +++ b/common/common/src/test/java/io/helidon/common/SizeTest.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.common; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SizeTest { + @Test + void testBytesEmpty() { + Size first = Size.create(0); + Size second = Size.ZERO; + + assertThat(first, is(second)); + assertThat(first.hashCode(), is(second.hashCode())); + + assertThat(first.toBytes(), is(0L)); + assertThat(second.toBytes(), is(0L)); + + for (Size.Unit unit : Size.Unit.values()) { + assertThat(first.to(unit), is(BigDecimal.ZERO)); + assertThat(second.to(unit), is(BigDecimal.ZERO)); + } + + assertThat(first.toString(), is("0 B")); + assertThat(first.toString(Size.UnitKind.DECIMAL_LOWER_CASE), is("0 b")); + assertThat(first.toString(Size.Unit.EIB, Size.UnitKind.BINARY_BI), is("0 EiB")); + } + + @Test + void testTooBig() { + Size size = Size.create(Long.MAX_VALUE, Size.Unit.KB); + assertThrows(ArithmeticException.class, size::toBytes); + } + + @Test + void testToStringWrongUnitKind() { + Size size = Size.create(1024, Size.Unit.KIB); + assertThrows(IllegalArgumentException.class, () -> size.toString(Size.Unit.EB, Size.UnitKind.BINARY_BI)); + assertThrows(IllegalArgumentException.class, () -> size.toString(Size.Unit.EB, Size.UnitKind.BINARY_UPPER_CASE)); + assertThrows(IllegalArgumentException.class, () -> size.toString(Size.Unit.EIB, Size.UnitKind.DECIMAL_UPPER_CASE)); + assertThrows(IllegalArgumentException.class, () -> size.toString(Size.Unit.EIB, Size.UnitKind.DECIMAL_LOWER_CASE)); + } + + @Test + void testConversionsBinary() { + Size size = Size.create(1, Size.Unit.EIB); + + assertThat(size.toBytes(), is(1152921504606846976L)); + + assertThat(size.to(Size.Unit.BYTE), is(new BigDecimal(Size.Unit.EIB.bytesInteger()))); + assertThat(size.to(Size.Unit.KIB), is(BigDecimal.valueOf(1125899906842624L))); + assertThat(size.to(Size.Unit.MIB), is(BigDecimal.valueOf(1099511627776L))); + assertThat(size.to(Size.Unit.GIB), is(BigDecimal.valueOf(1073741824L))); + assertThat(size.to(Size.Unit.TIB), is(BigDecimal.valueOf(1048576L))); + assertThat(size.to(Size.Unit.PIB), is(BigDecimal.valueOf(1024L))); + assertThat(size.to(Size.Unit.EIB), is(BigDecimal.valueOf(1L))); + + assertThat(size.toString(Size.UnitKind.BINARY_UPPER_CASE), is("1 EB")); + assertThat(size.toString(Size.UnitKind.BINARY_BI), is("1 EiB")); + assertThat(size.toString(Size.Unit.GIB, Size.UnitKind.BINARY_BI), is("1073741824 GiB")); + assertThat(size.toString(Size.Unit.GIB, Size.UnitKind.BINARY_UPPER_CASE), is("1073741824 GB")); + } + + @Test + void testConversionsDecimal() { + Size size = Size.create(1048576, Size.Unit.BYTE); + + assertThat(size.toBytes(), is(1048576L)); + + assertThat(size.to(Size.Unit.BYTE), is(BigDecimal.valueOf(1048576L))); + assertThat(size.to(Size.Unit.KB), is(new BigDecimal("1048.576"))); + assertThat(size.to(Size.Unit.MB), is(new BigDecimal("1.048576"))); + assertThat(size.to(Size.Unit.GB), closeTo(new BigDecimal("0.001048576"), BigDecimal.ZERO)); + assertThat(size.to(Size.Unit.TB), closeTo(new BigDecimal("0.000001048576"), BigDecimal.ZERO)); + assertThat(size.to(Size.Unit.PB), closeTo(new BigDecimal("0.000000001048576"), BigDecimal.ZERO)); + assertThat(size.to(Size.Unit.EB), closeTo(new BigDecimal("0.000000000001048576"), BigDecimal.ZERO)); + + assertThat(size.toString(), is("1048576 B")); + assertThat(size.toString(Size.UnitKind.DECIMAL_LOWER_CASE), is("1048576 b")); + assertThat(size.toString(Size.Unit.EB, Size.UnitKind.DECIMAL_UPPER_CASE), is("0.000000000001048576 eB")); + } + + @Test + void testParsingDecimal() { + testParsing("10", 10); + testParsing("2 kb", 2_000); + testParsing("2 kB", 2_000); + testParsing("3 mB", 3_000_000); + testParsing("3 mb", 3_000_000); + testParsing("4 gB", 4_000_000_000L); + testParsing("4 gb", 4_000_000_000L); + testParsing("7 tB", 7_000_000_000_000L); + testParsing("7 tb", 7_000_000_000_000L); + testParsing("5 pB", 5_000_000_000_000_000L); + testParsing("5 pb", 5_000_000_000_000_000L); + testParsing("6 eB", 6_000_000_000_000_000_000L); + testParsing("6 eb", 6_000_000_000_000_000_000L); + + testParsing("2.42 kb", 2_420); + testParsing("2.42 kB", 2_420); + testParsing("3.42 mB", 3_420_000); + testParsing("3.42 mb", 3_420_000); + testParsing("4.42 gB", 4_420_000_000L); + testParsing("4.42 gb", 4_420_000_000L); + testParsing("7.42 tB", 7_420_000_000_000L); + testParsing("7.42 tb", 7_420_000_000_000L); + testParsing("5.42 pB", 5_420_000_000_000_000L); + testParsing("5.42 pb", 5_420_000_000_000_000L); + testParsing("6.42 eB", 6_420_000_000_000_000_000L); + testParsing("6.42 eb", 6_420_000_000_000_000_000L); + } + + @Test + void testParsingBinary() { + testParsing("10", 10); + testParsing("2 KB", 2_048); + testParsing("2 KiB", 2_048); + testParsing("3 MB", 3_145_728); + testParsing("3 MiB", 3_145_728); + testParsing("4 GB", 4_294_967_296L); + testParsing("4 GiB", 4_294_967_296L); + testParsing("7 TB", 7_696_581_394_432L); + testParsing("7 TiB", 7_696_581_394_432L); + testParsing("5 PB", 5_629_499_534_213_120L); + testParsing("5 PiB", 5_629_499_534_213_120L); + testParsing("6 EB", 6_917_529_027_641_081_856L); + testParsing("6 EiB", 6_917_529_027_641_081_856L); + + testParsing("3.5 KB", 3_584); + testParsing("3.5 KiB", 3_584); + // not testing others, as this combines decimal numbers with binary numbers + } + + private void testParsing(String value, long numberOfBytes) { + Size size = Size.parse(value); + assertThat(size.toBytes(), is(numberOfBytes)); + } +} + diff --git a/common/types/src/main/java/io/helidon/common/types/TypeNames.java b/common/types/src/main/java/io/helidon/common/types/TypeNames.java index 62fe59da5a3..450fe80070e 100644 --- a/common/types/src/main/java/io/helidon/common/types/TypeNames.java +++ b/common/types/src/main/java/io/helidon/common/types/TypeNames.java @@ -28,6 +28,7 @@ import io.helidon.common.Generated; import io.helidon.common.GenericType; +import io.helidon.common.Size; /** * Commonly used type names. @@ -185,6 +186,10 @@ public final class TypeNames { * Helidon {@link io.helidon.common.GenericType}. */ public static final TypeName GENERIC_TYPE = TypeName.create(GenericType.class); + /** + * Type name for {@link io.helidon.common.Size}. + */ + public static final TypeName SIZE = TypeName.create(Size.class); private TypeNames() { } diff --git a/docs-internal/http-features.md b/docs-internal/http-features.md index 61c22338833..5eba617b5e6 100644 --- a/docs-internal/http-features.md +++ b/docs-internal/http-features.md @@ -11,6 +11,7 @@ Features | CORS | 850 | | Security | 800 | | Routing (all handlers) | 100 | +| Static Content | 95 | | OpenAPI | 90 | | Observe | 80 | diff --git a/docs/src/main/asciidoc/mp/server.adoc b/docs/src/main/asciidoc/mp/server.adoc index c6a7234ffe7..0cd3a31d304 100644 --- a/docs/src/main/asciidoc/mp/server.adoc +++ b/docs/src/main/asciidoc/mp/server.adoc @@ -333,24 +333,41 @@ io.helidon.examples.AdminService: .META-INF/microprofile-config.properties - File system static content ---- # Location of content on file system -server.static.path.location=/var/www/html -# default is index.html -server.static.path.welcome=resource.html -# static content path - default is "/" -# server.static.path.context=/static-file +server.features.static-content.path.0.location=/var/www/html +# default is index.html (only in Helidon MicroProfile) +server.features.static-content.path.0.welcome=resource.html +# static content context on webserver - default is "/" +# server.features.static-content.path.0.context=/static-file ---- [source,properties] .META-INF/microprofile-config.properties - Classpath static content ---- # src/main/resources/WEB in your source tree -server.static.classpath.location=/WEB +server.features.static-content.classpath.0.location=/WEB # default is index.html -server.static.classpath.welcome=resource.html +server.features.static-content.classpath.0.welcome=resource.html # static content path - default is "/" -# server.static.classpath.context=/static-cp +# server.features.static-content.classpath.0.context=/static-cp +---- + +It is usually easier to configure list-based options using `application.yaml` instead, such as: +[source,yaml] +.application.yaml - Static content +---- +server: + features: + static-content: + welcome: "welcome.html" + classpath: + - context: "/static" + location: "/WEB" + path: + - context: "/static-file" + location: "./static-content" ---- +See xref:{rootdir}/config/io_helidon_webserver_staticcontent_StaticContentFeature.adoc[Static Content Feature Configuration Reference] for details. The only difference is that we set welcome file to `index.html` by default. === Example configuration of routing diff --git a/docs/src/main/asciidoc/se/webserver.adoc b/docs/src/main/asciidoc/se/webserver.adoc index c1e8c433395..02e6f091ff6 100644 --- a/docs/src/main/asciidoc/se/webserver.adoc +++ b/docs/src/main/asciidoc/se/webserver.adoc @@ -626,12 +626,12 @@ To enable HTTP/2 support add the following dependency to your project's `pom.xml == Static Content Support -+Use the `io.helidon.webserver.staticcontent.StaticContentService` class to serve files and classpath resources. -`StaticContentService` can be created for any readable directory or classpath -context root and registered on a path in `HttpRouting`. +Static content is served through a `StaticContentFeature`. As with other server features, it can be configured through config, +or registered with server config builder. -You can combine dynamic handlers with `StaticContentService` objects: if no file matches the request path, then the request is forwarded to -the next handler. +Static content supports serving of files from classpath, or from any readable directory on the file system. +Each content handler must include a location, and can provide a context that will be registered with the WebServer +(defaults to `/`). === Maven Coordinates @@ -650,19 +650,39 @@ To enable Static Content Support add the following dependency to your project's To register static content based on a file system (`/pictures`), and classpath (`/`): [source,java] +.server feature using `WebServerConfig.Builder` ---- include::{sourcedir}/se/WebServerSnippets.java[tag=snippet_22, indent=0] ---- -<1> Create a new `StaticContentService` object to serve data from the file system, -and associate it with the `"/pictures"` context path. -<2> Create a `StaticContentService` object to serve resources from the contextual -`ClassLoader`. The specific classloader can be also -defined. A builder lets you provide more configuration values. -<3> `index.html` is the file that is returned if a directory is requested. +<1> Create a new `StaticContentFeature` to register with the web server (will be served on all sockets by default) +<2> Add path location served from `/some/WEB/pics` absolute path +<3> Associate the path location with server context `/pictures` +<4> Add classpath location to serve resources from the contextual +`ClassLoader` from location `/static-content` +<5> `index.html` is the file that is returned if a directory is requested +<6> serve the classpath content on root context `/` + +Static content can also be registered using the configuration of server feature. + +If you use `Config` with your webserver setup, you can register the same static content using configuration: + +[source,yaml] +.application.yaml +---- +server: + features: + static-content: + path: + - context: "/pictures" + location: "/some/WEB/pics" + classpath: + - context: "/" + welcome: "index.html" + location: "/static-content" +---- + +See xref:{rootdir}/config/io_helidon_webserver_staticcontent_StaticContentFeature.adoc[Static Content Feature Configuration Reference] for details of configuration options. -A `StaticContentService` object can be created using `create(...)` factory methods or a -`builder`. The `builder` lets you provide more configuration values, including _welcome file-name_ -and mappings of filename extensions to media types. == Media types support WebServer and WebClient share the HTTP media support of Helidon, and any supported media type can be used in both. diff --git a/docs/src/main/java/io/helidon/docs/se/WebServerSnippets.java b/docs/src/main/java/io/helidon/docs/se/WebServerSnippets.java index 5ba705ba717..c5a53aec33c 100644 --- a/docs/src/main/java/io/helidon/docs/se/WebServerSnippets.java +++ b/docs/src/main/java/io/helidon/docs/se/WebServerSnippets.java @@ -34,6 +34,7 @@ import io.helidon.http.media.jsonp.JsonpSupport; import io.helidon.webserver.ProxyProtocolData; import io.helidon.webserver.WebServer; +import io.helidon.webserver.WebServerConfig; import io.helidon.webserver.accesslog.AccessLogFeature; import io.helidon.webserver.http.HttpRoute; import io.helidon.webserver.http.HttpRouting; @@ -41,7 +42,7 @@ import io.helidon.webserver.http.HttpService; import io.helidon.webserver.http1.Http1Route; import io.helidon.webserver.http2.Http2Route; -import io.helidon.webserver.staticcontent.StaticContentService; +import io.helidon.webserver.staticcontent.StaticContentFeature; // tag::snippet_14[] import jakarta.json.Json; @@ -240,12 +241,15 @@ void snippet_21(HttpRules rules) { // end::snippet_21[] } - void snippet_22(HttpRouting.Builder routing) { + void snippet_22(WebServerConfig.Builder builder) { // tag::snippet_22[] - routing.register("/pictures", StaticContentService.create(Paths.get("/some/WEB/pics"))) // <1> - .register("/", StaticContentService.builder("/static-content") // <2> - .welcomeFileName("index.html") // <3> - .build()); + builder.addFeature(StaticContentFeature.builder() // <1> + .addPath(p -> p.location(Paths.get("/some/WEB/pics")) // <2> + .context("/pictures")) // <3> + .addClasspath(cl -> cl.location("/static-content") // <4> + .welcome("index.html") // <5> + .context("/")) // <6> + .build()); // end::snippet_22[] } diff --git a/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java b/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java index 1866eb47d09..fd3b9ef1738 100644 --- a/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java +++ b/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2023 Oracle and/or its affiliates. + * Copyright (c) 2018, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -56,7 +56,7 @@ import io.helidon.webserver.observe.ObserveFeatureConfig; import io.helidon.webserver.observe.spi.Observer; import io.helidon.webserver.spi.ServerFeature; -import io.helidon.webserver.staticcontent.StaticContentService; +import io.helidon.webserver.staticcontent.StaticContentConfig; import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; @@ -383,8 +383,10 @@ private void registerKpiMetricsDeferrableRequestContextSetterHandler(JaxRsCdiExt if (!routingsWithKPIMetrics.contains(routing)) { routingsWithKPIMetrics.add(routing); routing.any(KeyPerformanceIndicatorSupport.DeferrableRequestContext.CONTEXT_SETTING_HANDLER); - LOGGER.log(Level.TRACE, () -> String.format("Adding deferrable request KPI metrics context for routing with name '%s'" - + "", namedRouting.orElse(""))); + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, String.format("Adding deferrable request KPI metrics context for routing with name '%s'", + namedRouting.orElse(""))); + } } } @@ -557,40 +559,64 @@ private void registerDefaultRedirect() { } private void registerStaticContent() { - Config config = (Config) ConfigProvider.getConfig(); - config = config.get("server.static"); + Config rootConfig = (Config) ConfigProvider.getConfig(); + Config config = rootConfig.get("server.static"); + + if (config.exists()) { + LOGGER.log(Level.WARNING, "Configuration of static content through \"server.static\" is now deprecated." + + " Please use \"server.features.static-content\", with sub-keys \"path\" and/or \"classpath\"" + + " containing a list of handlers. At least \"context\" and \"location\" should be provided for each handler." + + " Location for classpath is the resource location with static content, for path it is the" + + " location on file system with the root of static content. For advanced configuration such as" + + " in-memory caching, temporary storage setup etc. kindly see our config reference for " + + "\"StaticContentFeature\" in documentation."); + } config.get("classpath") .ifExists(this::registerClasspathStaticContent); config.get("path") .ifExists(this::registerPathStaticContent); + + Config featureConfig = rootConfig.get("server.features.static-content"); + if (featureConfig.exists()) { + var builder = StaticContentConfig.builder() + .config(featureConfig); + if (builder.welcome().isEmpty()) { + builder.welcome("index.html"); + } + addFeature(builder.build()); + } } + @SuppressWarnings("removal") private void registerPathStaticContent(Config config) { Config context = config.get("context"); - StaticContentService.FileSystemBuilder pBuilder = StaticContentService.builder(config.get("location") + io.helidon.webserver.staticcontent.StaticContentService.FileSystemBuilder pBuilder = + io.helidon.webserver.staticcontent.StaticContentService.builder(config.get("location") .as(Path.class) .get()); pBuilder.welcomeFileName(config.get("welcome") .asString() .orElse("index.html")); - StaticContentService staticContent = pBuilder.build(); + var staticContent = pBuilder.build(); if (context.exists()) { routingBuilder.register(context.asString().get(), staticContent); } else { - Supplier ms = () -> staticContent; + Supplier ms = () -> staticContent; routingBuilder.register(ms); } STARTUP_LOGGER.log(Level.TRACE, "Static path"); } + @SuppressWarnings("removal") private void registerClasspathStaticContent(Config config) { Config context = config.get("context"); - StaticContentService.ClassPathBuilder cpBuilder = StaticContentService.builder(config.get("location").asString().get()); + io.helidon.webserver.staticcontent.StaticContentService.ClassPathBuilder cpBuilder = + io.helidon.webserver.staticcontent.StaticContentService.builder(config.get("location").asString().get()); cpBuilder.welcomeFileName(config.get("welcome") .asString() .orElse("index.html")); @@ -604,7 +630,7 @@ private void registerClasspathStaticContent(Config config) { .flatMap(List::stream) .forEach(cpBuilder::addCacheInMemory); - StaticContentService staticContent = cpBuilder.build(); + var staticContent = cpBuilder.build(); if (context.exists()) { routingBuilder.register(context.asString().get(), staticContent); diff --git a/tests/integration/packaging/mp-1/src/main/resources/META-INF/microprofile-config.properties b/tests/integration/packaging/mp-1/src/main/resources/META-INF/microprofile-config.properties index d5a17f82f33..b91e53bffac 100644 --- a/tests/integration/packaging/mp-1/src/main/resources/META-INF/microprofile-config.properties +++ b/tests/integration/packaging/mp-1/src/main/resources/META-INF/microprofile-config.properties @@ -27,6 +27,6 @@ tracing.global=false features.print-details=true -server.static.classpath.context=/static -server.static.classpath.location=/web -server.static.classpath.welcome=welcome.txt \ No newline at end of file +server.features.static-content.classpath.0.context=/static +server.features.static-content.classpath.0.location=/web +server.features.static-content.classpath.0.welcome=welcome.txt \ No newline at end of file diff --git a/webserver/static-content/etc/spotbugs/exclude.xml b/webserver/static-content/etc/spotbugs/exclude.xml index b88fd4ce2e8..387c959faba 100644 --- a/webserver/static-content/etc/spotbugs/exclude.xml +++ b/webserver/static-content/etc/spotbugs/exclude.xml @@ -1,7 +1,7 @@ + + + + + + + @@ -39,4 +45,10 @@ + + + + + + diff --git a/webserver/static-content/pom.xml b/webserver/static-content/pom.xml index 511549318c8..4f9fed7ecaf 100644 --- a/webserver/static-content/pom.xml +++ b/webserver/static-content/pom.xml @@ -61,6 +61,11 @@ mockito-core test + + io.helidon.logging + helidon-logging-jul + test + @@ -75,8 +80,50 @@ helidon-common-features-processor ${helidon.version} + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/BaseHandlerConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/BaseHandlerConfigBlueprint.java new file mode 100644 index 00000000000..2656bb5723d --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/BaseHandlerConfigBlueprint.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.media.type.MediaType; + +/** + * Configuration of static content handlers that is common for classpath and file system based handlers. + */ +@Prototype.Blueprint(createEmptyPublic = false, createFromConfigPublic = false) +@Prototype.Configured +@Prototype.CustomMethods(StaticContentConfigSupport.BaseMethods.class) +interface BaseHandlerConfigBlueprint { + /** + * Whether this handle is enabled, defaults to {@code true}. + * + * @return whether enabled + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean enabled(); + + /** + * Context that will serve this handler's static resources, defaults to {@code /}. + * + * @return context under webserver + */ + @Option.Configured + @Option.Default("/") + String context(); + + /** + * Sockets names (listeners) that will host this static content handler, defaults to all configured sockets. + * Default socket name is {@code @default}. + * + * @return sockets to register this handler on + */ + @Option.Configured + @Option.Singular + Set sockets(); + + /** + * Welcome-file name. In case a directory is requested, this file would be served if present. + * There is no welcome file by default. + * + * @return welcome-file name, such as {@code index.html} + */ + @Option.Configured + Optional welcome(); + + /** + * A set of files that are cached in memory at startup. These files are never removed from the in-memory cache, though + * their overall size is added to the memory cache used bytes. + * When using classpath, the set must contain explicit list of all files that should be cached, when using file system, + * it can contain a directory, and all files under that directory (recursive) would be cached as well. + *

+ * Note that files cached through this method may use more than the max-bytes configured for the in-memory cache (i.e. + * this option wins over the maximal size in bytes), so kindly be careful with what is pushed to the cache. + *

+ * Files cached in memory will never be re-loaded, even if changed, until server restart! + * + * @return set of file names (or directory names if not using classpath) to cache in memory on startup + */ + @Option.Configured + @Option.Singular + Set cachedFiles(); + + /** + * Handles will use memory cache configured on {@link StaticContentConfig#memoryCache()} by default. + * In case a memory cache is configured here, it will replace the memory cache used by the static content feature, and this + * handle will use a dedicated memory cache instead. + *

+ * To disable memory caching for a single handler, create the configuration, and set {@code enabled: false}. + * + * @return memory cache to use with this handler + */ + @Option.Configured + Optional memoryCache(); + + /** + * Maps a filename extension to the response content type. + * To have a system-wide configuration, you can use the service loader SPI + * {@link io.helidon.common.media.type.spi.MediaTypeDetector}. + *

+ * This method can override {@link io.helidon.common.media.type.MediaTypes} detection + * for a specific static content handler. + *

+ * Handler will use a union of configuration on the {@link io.helidon.webserver.staticcontent.StaticContentConfig} and + * here when used from configuration. + * + * @return map of file extensions to associated media type + */ + @Option.Configured + @Option.Singular + Map contentTypes(); + + /** + * Map request path to resource path. Default uses the same path as requested. + * This can be used to resolve all paths to a single file, or to filter out files. + * + * @return function to map request path to resource path + */ + @Option.DefaultMethod("identity") + Function pathMapper(); + + /** + * Configure capacity of cache used for resources. This cache will make sure the media type and location is discovered + * faster. + *

+ * To cache content (bytes) in memory, use {@link io.helidon.webserver.staticcontent.BaseHandlerConfig#memoryCache()} + * + * @return maximal number of cached records, only caches media type and Path, not the content + */ + @Option.Configured + Optional recordCacheCapacity(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerInMemory.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerInMemory.java index 06ac5ab9d7a..7511b68e386 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerInMemory.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerInMemory.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -78,7 +78,7 @@ private void send(ServerRequest request, ServerResponse response) { contentLength); if (ranges.size() == 1) { // single response - ByteRangeRequest range = ranges.get(0); + ByteRangeRequest range = ranges.getFirst(); if (range.offset() > contentLength()) { throw new HttpException("Invalid range offset", Status.REQUESTED_RANGE_NOT_SATISFIABLE_416, true); diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerJar.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerJar.java index 62612bd01db..6209185a0f0 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerJar.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerJar.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,26 +18,80 @@ package io.helidon.webserver.staticcontent; import java.io.IOException; +import java.io.InputStream; +import java.lang.System.Logger.Level; +import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.time.Instant; import java.util.function.BiConsumer; import io.helidon.common.configurable.LruCache; import io.helidon.common.media.type.MediaType; +import io.helidon.http.Header; +import io.helidon.http.HeaderNames; +import io.helidon.http.HeaderValues; import io.helidon.http.Method; import io.helidon.http.ServerResponseHeaders; import io.helidon.webserver.http.ServerRequest; import io.helidon.webserver.http.ServerResponse; +import static io.helidon.webserver.staticcontent.StaticContentHandler.formatLastModified; import static io.helidon.webserver.staticcontent.StaticContentHandler.processEtag; import static io.helidon.webserver.staticcontent.StaticContentHandler.processModifyHeaders; -record CachedHandlerJar(Path path, - MediaType mediaType, - Instant lastModified, - BiConsumer setLastModifiedHeader) implements CachedHandler { +/** + * Handles a jar file entry. + * The entry may be extracted into a temporary file (optional). + */ +class CachedHandlerJar implements CachedHandler { private static final System.Logger LOGGER = System.getLogger(CachedHandlerJar.class.getName()); + private final MediaType mediaType; + private final Header contentLength; + private final Instant lastModified; + private final BiConsumer setLastModifiedHeader; + private final Path path; + private final URL url; + + private CachedHandlerJar(MediaType mediaType, + URL url, + long contentLength, + Instant lastModified, + BiConsumer setLastModifiedHeader, + Path path) { + this.mediaType = mediaType; + this.url = url; + this.contentLength = HeaderValues.create(HeaderNames.CONTENT_LENGTH, true, false, contentLength); + this.lastModified = lastModified; + this.setLastModifiedHeader = setLastModifiedHeader; + this.path = path; + } + + static CachedHandlerJar create(TemporaryStorage tmpStorage, + URL fileUrl, + Instant lastModified, + MediaType mediaType, + long contentLength) { + + BiConsumer headerHandler = headerHandler(lastModified); + + var createdTmpFile = tmpStorage.createFile(); + if (createdTmpFile.isPresent()) { + // extract entry + Path tmpFile = createdTmpFile.get(); + try (InputStream is = fileUrl.openStream()) { + Files.copy(is, tmpFile, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + // silently consume the exception, as the tmp file may have been removed, we may throw when reading the file + LOGGER.log(Level.TRACE, "Failed to create temporary extracted file for " + fileUrl, e); + } + return new CachedHandlerJar(mediaType, fileUrl, contentLength, lastModified, headerHandler, tmpFile); + } else { + // use the entry always + return new CachedHandlerJar(mediaType, fileUrl, contentLength, lastModified, headerHandler, null); + } + } @Override public boolean handle(LruCache cache, @@ -46,32 +100,52 @@ public boolean handle(LruCache cache, ServerResponse response, String requestedResource) throws IOException { - // check if file still exists (the tmp may have been removed, file may have been removed - // there is still a race change, but we do not want to keep cached records for invalid files - if (!Files.exists(path)) { - cache.remove(requestedResource); - return false; - } - - if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { - LOGGER.log(System.Logger.Level.TRACE, "Sending static content from jar: " + requestedResource); + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, "Sending static content from jar: " + requestedResource); } // etag etc. if (lastModified != null) { processEtag(String.valueOf(lastModified.toEpochMilli()), request.headers(), response.headers()); - processModifyHeaders(lastModified, request.headers(), response.headers(), setLastModifiedHeader()); + processModifyHeaders(lastModified, request.headers(), response.headers(), setLastModifiedHeader); } response.headers().contentType(mediaType); if (method == Method.GET) { - FileBasedContentHandler.send(request, response, path); + try { + if (path != null && Files.exists(path)) { + FileBasedContentHandler.send(request, response, path); + return true; + } + } catch (IOException e) { + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, "Failed to send jar entry from extracted path: " + path + + ", will send directly from jar", + e); + } + } + try (var in = url.openStream(); var out = response.outputStream()) { + // no support for ranges when using jar stream + in.transferTo(out); + } } else { - response.headers().contentLength(FileBasedContentHandler.contentLength(path)); + response.headers().set(contentLength); response.send(); } return true; } + + private static BiConsumer headerHandler(Instant lastModified) { + if (lastModified == null) { + return (headers, instant) -> { + }; + } + Header instantHeader = HeaderValues.create(HeaderNames.LAST_MODIFIED, + true, + false, + formatLastModified(lastModified)); + return (headers, instant) -> headers.set(instantHeader); + } } diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java index 7d2dce9cbbb..3e2ec31db05 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java @@ -24,19 +24,14 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.time.Instant; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.BiFunction; +import java.util.function.BiConsumer; +import java.util.function.Supplier; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -46,6 +41,7 @@ import io.helidon.http.HeaderValues; import io.helidon.http.InternalServerException; import io.helidon.http.Method; +import io.helidon.http.ServerResponseHeaders; import io.helidon.webserver.http.ServerRequest; import io.helidon.webserver.http.ServerResponse; @@ -59,49 +55,23 @@ class ClassPathContentHandler extends FileBasedContentHandler { private final ClassLoader classLoader; private final String root; private final String rootWithTrailingSlash; - private final BiFunction tmpFile; private final Set cacheInMemory; + private final TemporaryStorage tmpStorage; - // URL's hash code and equal are not suitable for map or set - private final Map extracted = new HashMap<>(); - private final ReentrantLock lock = new ReentrantLock(); + ClassPathContentHandler(ClasspathHandlerConfig config) { + super(config); - ClassPathContentHandler(StaticContentService.ClassPathBuilder builder) { - super(builder); - - this.classLoader = builder.classLoader(); - this.cacheInMemory = new HashSet<>(builder.cacheInMemory()); - this.root = builder.root(); + this.classLoader = config.classLoader().orElseGet(() -> Thread.currentThread().getContextClassLoader()); + this.cacheInMemory = new HashSet<>(config.cachedFiles()); + this.root = cleanRoot(config.location()); this.rootWithTrailingSlash = root + '/'; - Path tmpDir = builder.tmpDir(); - if (tmpDir == null) { - this.tmpFile = (prefix, suffix) -> { - try { - return Files.createTempFile(prefix, suffix); - } catch (IOException e) { - throw new InternalServerException("Failed to create temporary file", e, true); - } - }; - } else { - this.tmpFile = (prefix, suffix) -> { - try { - return Files.createTempFile(tmpDir, prefix, suffix); - } catch (IOException e) { - throw new InternalServerException("Failed to create temporary file", e, true); - } - }; - } + this.tmpStorage = config.temporaryStorage().orElseGet(TemporaryStorage::create); } - static String fileName(URL url) { - String path = url.getPath(); - int index = path.lastIndexOf('/'); - if (index > -1) { - return path.substring(index + 1); - } - - return path; + @SuppressWarnings("removal") // will be replaced with HttpService once removed + static StaticContentService create(ClasspathHandlerConfig config) { + return new ClassPathContentHandler(config); } @Override @@ -123,7 +93,6 @@ void releaseCache() { populatedInMemoryCache.set(false); } - @SuppressWarnings("checkstyle:RegexpSinglelineJava") @Override boolean doHandle(Method method, String requestedPath, ServerRequest request, ServerResponse response, boolean mapped) throws IOException, URISyntaxException { @@ -214,6 +183,16 @@ boolean doHandle(Method method, String requestedPath, ServerRequest request, Ser return cachedHandler.handle(handlerCache(), method, request, response, requestedResource); } + private static String fileName(URL url) { + String path = url.getPath(); + int index = path.lastIndexOf('/'); + if (index > -1) { + return path.substring(index + 1); + } + + return path; + } + private String requestedResource(String rawPath, String requestedPath, boolean mapped) throws URISyntaxException { String resource = requestedPath.isEmpty() || "/".equals(requestedPath) ? root : (rootWithTrailingSlash + requestedPath); @@ -232,82 +211,99 @@ private String requestedResource(String rawPath, String requestedPath, boolean m return rawPath.endsWith("/") ? result + "/" : result; } - private Optional jarHandler(String requestedResource, URL url) { - ExtractedJarEntry extrEntry; - lock.lock(); - try { - extrEntry = extracted.compute(requestedResource, (key, entry) -> existOrCreate(url, entry)); - } finally { - lock.unlock(); - } + private Optional jarHandler(String requestedResource, URL url) throws IOException { + JarURLConnection jarUrlConnection = (JarURLConnection) url.openConnection(); + JarEntry jarEntry = jarUrlConnection.getJarEntry(); - if (extrEntry.tempFile == null) { - // once again, not caching 404 + if (jarEntry.isDirectory()) { + // we cannot cache this - as we consider this to be 404 return Optional.empty(); } - Instant lastModified = extrEntry.lastModified(); - if (lastModified == null) { - return Optional.of(new CachedHandlerJar(extrEntry.tempFile, - detectType(extrEntry.entryName), - null, - null)); - } else { - // we can cache this, as this is a jar record + var contentLength = jarEntry.getSize(); + var contentType = detectType(fileName(url)); + Optional lastModified; + + try (JarFile jarFile = jarUrlConnection.getJarFile()) { + lastModified = lastModified(jarFile.getName()); + } + + var lastModifiedHandler = lastModifiedHandler(lastModified); + + /* + We have all the information we need to process a jar file + Now we have two options: + 1. The file will be cached in memory + 2. The file will be handled through CachedHandlerJar (and possibly extracted to a temporary directory) + */ + if (contentLength <= Integer.MAX_VALUE && canCacheInMemory((int) contentLength)) { + // we may be able to cache this entry + var cached = cacheInMemory(requestedResource, + (int) contentLength, + inMemorySupplier(url, + lastModified.orElse(null), + lastModifiedHandler, + contentType, + contentLength)); + if (cached.isPresent()) { + // we have successfully cached the entry in memory + return Optional.of(cached.get()); + } + } + + // cannot cache in memory (too big file, cache full) + CachedHandlerJar jarHandler = CachedHandlerJar.create(tmpStorage, + url, + lastModified.orElse(null), + contentType, + contentLength); + + return Optional.of(jarHandler); + } + + private BiConsumer lastModifiedHandler(Optional lastModified) { + if (lastModified.isPresent()) { Header lastModifiedHeader = HeaderValues.create(HeaderNames.LAST_MODIFIED, true, false, - formatLastModified(lastModified)); - return Optional.of(new CachedHandlerJar(extrEntry.tempFile, - detectType(extrEntry.entryName), - extrEntry.lastModified(), - (headers, instant) -> headers.set(lastModifiedHeader))); + formatLastModified(lastModified.get())); + return (headers, instant) -> headers.set(lastModifiedHeader); + } else { + return (headers, instant) -> { + }; } } - private ExtractedJarEntry existOrCreate(URL url, ExtractedJarEntry entry) { - if (entry == null) { - return extractJarEntry(url); - } - if (entry.tempFile == null) { - return entry; - } - if (Files.notExists(entry.tempFile)) { - return extractJarEntry(url); - } - return entry; + private Supplier inMemorySupplier(URL url, + Instant lastModified, + BiConsumer lastModifiedHandler, + MediaType contentType, + long contentLength) { + + Header contentLengthHeader = HeaderValues.create(HeaderNames.CONTENT_LENGTH, + contentLength); + return () -> { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (InputStream in = url.openStream()) { + in.transferTo(baos); + } catch (IOException e) { + throw new InternalServerException("Cannot load resource", e); + } + byte[] bytes = baos.toByteArray(); + return new CachedHandlerInMemory(contentType, + lastModified, + lastModifiedHandler, + bytes, + bytes.length, + contentLengthHeader); + }; } private Optional urlStreamHandler(URL url) { return Optional.of(new CachedHandlerUrlStream(detectType(fileName(url)), url)); } - private ExtractedJarEntry extractJarEntry(URL url) { - try { - JarURLConnection jarUrlConnection = (JarURLConnection) url.openConnection(); - JarFile jarFile = jarUrlConnection.getJarFile(); - JarEntry jarEntry = jarUrlConnection.getJarEntry(); - if (jarEntry.isDirectory()) { - return new ExtractedJarEntry(jarEntry.getName()); // a directory - } - Optional lastModified = lastModified(jarFile.getName()); - - // Extract JAR entry to file - try (InputStream is = jarFile.getInputStream(jarEntry)) { - Path tempFile = tmpFile.apply("ws", ".je"); - Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING); - return new ExtractedJarEntry(tempFile, lastModified.orElse(null), jarEntry.getName()); - } finally { - if (!jarUrlConnection.getUseCaches()) { - jarFile.close(); - } - } - } catch (IOException ioe) { - throw new InternalServerException("Cannot load resource", ioe); - } - } - - private void addToInMemoryCache(String resource) throws IOException, URISyntaxException { + private void addToInMemoryCache(String resource) throws IOException { /* we need to know: - content size @@ -351,12 +347,19 @@ private void addToInMemoryCache(String resource) throws IOException, URISyntaxEx cacheInMemory(requestedResource, contentType, entityBytes, lastModified); } - private Optional lastModified(URL url) throws URISyntaxException, IOException { - return switch (url.getProtocol()) { - case "file" -> lastModified(Paths.get(url.toURI())); - case "jar" -> lastModifiedFromJar(url); - default -> Optional.empty(); - }; + private Optional lastModified(URL url) { + try { + return switch (url.getProtocol()) { + case "file" -> lastModified(Paths.get(url.toURI())); + case "jar" -> lastModifiedFromJar(url); + default -> Optional.empty(); + }; + } catch (IOException | URISyntaxException e) { + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, "Failed to get last modification of a file for URL: " + url, e); + } + return Optional.empty(); + } } private Optional lastModifiedFromJar(URL url) throws IOException { @@ -369,12 +372,18 @@ private Optional lastModified(String path) throws IOException { return lastModified(Paths.get(path)); } - private record ExtractedJarEntry(Path tempFile, Instant lastModified, String entryName) { - /** - * Creates directory representation. - */ - ExtractedJarEntry(String entryName) { - this(null, null, entryName); + private static String cleanRoot(String location) { + String cleanRoot = location; + if (cleanRoot.startsWith("/")) { + cleanRoot = cleanRoot.substring(1); + } + while (cleanRoot.endsWith("/")) { + cleanRoot = cleanRoot.substring(0, cleanRoot.length() - 1); + } + + if (cleanRoot.isEmpty()) { + throw new IllegalArgumentException("Cannot serve full classpath, please configure a classpath prefix"); } + return cleanRoot; } } diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClasspathHandlerConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClasspathHandlerConfigBlueprint.java new file mode 100644 index 00000000000..0c76306a30f --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClasspathHandlerConfigBlueprint.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.util.Optional; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +/** + * Classpath based static content handler configuration. + */ +@Prototype.Configured +@Prototype.Blueprint +@Prototype.CustomMethods(StaticContentConfigSupport.ClasspathMethods.class) +interface ClasspathHandlerConfigBlueprint extends BaseHandlerConfigBlueprint { + /** + * The location on classpath that contains the root of the static content. + * This should never be the root (i.e. {@code /}), as that would allow serving of all class files. + * + * @return location on classpath to serve the static content, such as {@code "/web"}. + */ + @Option.Configured + String location(); + + /** + * Customization of temporary storage configuration. + * + * @return temporary storage config + */ + @Option.Configured + Optional temporaryStorage(); + + /** + * Class loader to use to lookup the static content resources from classpath. + * + * @return class loader to use + */ + Optional classLoader(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java index 68dc364ffdf..e7eeb87766e 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, 2023 Oracle and/or its affiliates. + * Copyright (c) 2021, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,10 +44,10 @@ abstract class FileBasedContentHandler extends StaticContentHandler { private final Map customMediaTypes; - FileBasedContentHandler(StaticContentService.FileBasedBuilder builder) { - super(builder); + FileBasedContentHandler(BaseHandlerConfig config) { + super(config); - this.customMediaTypes = builder.specificContentTypes(); + this.customMediaTypes = config.contentTypes(); } static String fileName(Path path) { @@ -82,7 +82,7 @@ static void send(ServerRequest request, ServerResponse response, Path path) thro contentLength); if (ranges.size() == 1) { // single response - ByteRangeRequest range = ranges.get(0); + ByteRangeRequest range = ranges.getFirst(); range.setContentRange(response); // only send a part of the file diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java index d0ab9570794..233c46670e0 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java @@ -20,7 +20,6 @@ import java.lang.System.Logger.Level; import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashSet; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; @@ -40,11 +39,21 @@ class FileSystemContentHandler extends FileBasedContentHandler { private final Path root; private final Set cacheInMemory; - FileSystemContentHandler(StaticContentService.FileSystemBuilder builder) { - super(builder); + FileSystemContentHandler(FileSystemHandlerConfig config) { + super(config); - this.root = builder.root().toAbsolutePath().normalize(); - this.cacheInMemory = new HashSet<>(builder.cacheInMemory()); + this.root = config.location().toAbsolutePath().normalize(); + this.cacheInMemory = config.cachedFiles(); + } + + @SuppressWarnings("removal") + static StaticContentService create(FileSystemHandlerConfig config) { + Path location = config.location(); + if (Files.isDirectory(location)) { + return new FileSystemContentHandler(config); + } else { + return new SingleFileContentHandler(config); + } } @Override @@ -175,7 +184,7 @@ private void addToInMemoryCache(String resource) throws IOException { if (Files.isDirectory(path)) { try (var paths = Files.newDirectoryStream(path)) { - paths.forEach(child -> { + paths.forEach(child -> { if (!Files.isDirectory(child)) { // we need to use forward slash even on Windows String childResource = root.relativize(child).toString().replace('\\', '/'); diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemHandlerConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemHandlerConfigBlueprint.java new file mode 100644 index 00000000000..bc24cb3a8a4 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemHandlerConfigBlueprint.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.nio.file.Path; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +/** + * File system based static content handler configuration. + */ +@Prototype.Configured +@Prototype.Blueprint +@Prototype.CustomMethods(StaticContentConfigSupport.FileSystemMethods.class) +interface FileSystemHandlerConfigBlueprint extends BaseHandlerConfigBlueprint { + /** + * The directory (or a single file) that contains the root of the static content. + * + * @return location to serve the static content, such as {@code "/home/user/static-content"}. + */ + @Option.Configured + Path location(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/IoSupplier.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/IoSupplier.java new file mode 100644 index 00000000000..3b8b2d46a7b --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/IoSupplier.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.io.IOException; + +@FunctionalInterface +interface IoSupplier { + T get() throws IOException; +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCache.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCache.java new file mode 100644 index 00000000000..ae065de3a91 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCache.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import io.helidon.builder.api.RuntimeType; + +/** + * Memory cache to allow in-memory storage of static content, rather than reading it from file system each time the + * resource is requested. + */ +@RuntimeType.PrototypedBy(MemoryCacheConfig.class) +public class MemoryCache implements RuntimeType.Api { + private final MemoryCacheConfig config; + private final long maxSize; + // cache is Map Map CachedHandlerInMemory>> + private final Map> cache = new IdentityHashMap<>(); + private final ReadWriteLock cacheLock = new ReentrantReadWriteLock(); + + private final ReentrantLock sizeLock = new ReentrantLock(); + private long currentSize; + + private MemoryCache(MemoryCacheConfig config) { + this.config = config; + if (config.enabled()) { + long configuredMax = config.capacity().toBytes(); + this.maxSize = configuredMax == 0 ? Long.MAX_VALUE : configuredMax; + } else { + this.maxSize = 0; + } + } + + /** + * A new builder to configure and create a memory cache. + * + * @return a new fluent API builder + */ + public static MemoryCacheConfig.Builder builder() { + return MemoryCacheConfig.builder(); + } + + /** + * Create a new memory cache from its configuration. + * + * @param config memory cache configuration + * @return a new configured memory cache + */ + public static MemoryCache create(MemoryCacheConfig config) { + return new MemoryCache(config); + } + + /** + * Create a new memory cache customizing its configuration. + * + * @param consumer configuration consumer + * @return a new configured memory cache + */ + public static MemoryCache create(Consumer consumer) { + return builder().update(consumer).build(); + } + + /** + * Create an in-memory cache with zero capacity. Only + * {@link #cache(StaticContentHandler, String, CachedHandlerInMemory)} will be stored in it, as these are considered + * required cache records. All calls to {@link #cache(StaticContentHandler, String, int, java.util.function.Supplier)} will + * return empty. + * + * @return a new memory cache with capacity set to zero + */ + public static MemoryCache create() { + return builder().enabled(false).build(); + } + + @Override + public MemoryCacheConfig prototype() { + return config; + } + + void clear(StaticContentHandler staticContentHandler) { + try { + cacheLock.writeLock().lock(); + cache.remove(staticContentHandler); + } finally { + cacheLock.writeLock().unlock(); + } + } + + /** + * Is there a possibility to cache the bytes. + * There may be a race, so {@link #cache(StaticContentHandler, String, int, java.util.function.Supplier)} may still return + * empty. + * + * @return if there is space in the cache for the number of bytes requested + */ + boolean available(int bytes) { + return maxSize != 0 && (currentSize + bytes) <= maxSize; + } + + Optional cache(StaticContentHandler handler, + String resource, + int size, + Supplier handlerSupplier) { + try { + sizeLock.lock(); + if (maxSize == 0 || currentSize + size > maxSize) { + // either we are not enabled, or the size would be bigger than maximal size + return Optional.empty(); + } + // increase current size + currentSize += size; + } finally { + sizeLock.unlock(); + } + try { + cacheLock.writeLock().lock(); + CachedHandlerInMemory cachedHandlerInMemory = handlerSupplier.get(); + cache.computeIfAbsent(handler, k -> new HashMap<>()) + .put(resource, cachedHandlerInMemory); + return Optional.of(cachedHandlerInMemory); + } finally { + cacheLock.writeLock().unlock(); + } + } + + // hard add to cache, even if disabled (for explicitly configured resources to cache in memory) + void cache(StaticContentHandler handler, String resource, CachedHandlerInMemory inMemoryHandler) { + try { + sizeLock.lock(); + if (maxSize != 0) { + // only increase current size if enabled, otherwise it does not matter + currentSize += inMemoryHandler.contentLength(); + } + } finally { + sizeLock.unlock(); + } + try { + cacheLock.writeLock().lock(); + cache.computeIfAbsent(handler, k -> new HashMap<>()) + .put(resource, inMemoryHandler); + } finally { + cacheLock.writeLock().unlock(); + } + } + + Optional get(StaticContentHandler handler, String resource) { + try { + cacheLock.readLock().lock(); + Map resourceCache = cache.get(handler); + if (resourceCache == null) { + return Optional.empty(); + } + return Optional.ofNullable(resourceCache.get(resource)); + } finally { + cacheLock.readLock().unlock(); + } + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCacheConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCacheConfigBlueprint.java new file mode 100644 index 00000000000..f2d6eaed31f --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCacheConfigBlueprint.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.Size; + +/** + * Configuration of memory cache for static content. + * The memory cache will cache the first {@link #capacity() bytes} that fit into the configured memory size for the + * duration of the service uptime. + */ +@Prototype.Blueprint +@Prototype.Configured +interface MemoryCacheConfigBlueprint extends Prototype.Factory { + /** + * Whether the cache is enabled, defaults to {@code true}. + * + * @return whether the cache is enabled + */ + @Option.DefaultBoolean(true) + @Option.Configured + boolean enabled(); + + /** + * Capacity of the cached bytes of file content. + * If set to {@code 0}, the cache is unlimited. To disable caching, set {@link #enabled()} to {@code false}, + * or do not configure a memory cache at all. + *

+ * The capacity must be less than {@link java.lang.Long#MAX_VALUE} bytes, though you must be careful still, + * as it must fit into the heap size. + * + * @return capacity of the cache in bytes, defaults to 50 million bytes (50 mB) + */ + @Option.Default("50 mB") + @Option.Configured + Size capacity(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/SingleFileContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/SingleFileContentHandler.java index b737bfdf670..c5e4141a416 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/SingleFileContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/SingleFileContentHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Oracle and/or its affiliates. + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,11 +32,11 @@ class SingleFileContentHandler extends FileBasedContentHandler { private final boolean cacheInMemory; private final Path path; - SingleFileContentHandler(FileSystemBuilder builder) { - super(builder); + SingleFileContentHandler(FileSystemHandlerConfig config) { + super(config); - this.cacheInMemory = builder.cacheInMemory().contains(".") || builder.cacheInMemory().contains("/"); - this.path = builder.root().toAbsolutePath().normalize(); + this.cacheInMemory = config.cachedFiles().contains(".") || config.cachedFiles().contains("/"); + this.path = config.location().toAbsolutePath().normalize(); } @Override diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigBlueprint.java new file mode 100644 index 00000000000..6a16496d69e --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigBlueprint.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.media.type.MediaType; +import io.helidon.webserver.spi.ServerFeatureProvider; + +/** + * Configuration of Static content feature. + *

+ * Minimal example configuring a single classpath resource (properties): + *

+ * server.features.static-content.classpath.0.context=/static
+ * server.features.static-content.classpath.0.location=/web
+ * 
+ * and using yaml: + *
+ * server:
+ *   features:
+ *     static-content:
+ *       classpath:
+ *         - context: "/static"
+ *           location: "/web"
+ * 
+ */ +@Prototype.Blueprint +@Prototype.Configured(value = StaticContentFeature.STATIC_CONTENT_ID, root = false) +@Prototype.Provides(ServerFeatureProvider.class) +@Prototype.CustomMethods(StaticContentConfigSupport.StaticContentMethods.class) +interface StaticContentConfigBlueprint extends Prototype.Factory { + /** + * Whether this feature is enabled, defaults to {@code true}. + * + * @return whether this feature is enabled + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean enabled(); + + /** + * Weight of the static content feature. Defaults to + * {@value StaticContentFeature#WEIGHT}. + * + * @return weight of the feature + */ + @Option.DefaultDouble(StaticContentFeature.WEIGHT) + @Option.Configured + double weight(); + + + /** + * Name of this instance. + * + * @return instance name + */ + @Option.Default(StaticContentFeature.STATIC_CONTENT_ID) + String name(); + + /** + * Memory cache shared by the whole feature. + * If not configured, files are not cached in memory (except for explicitly marked files/resources in each section). + * + * @return memory cache, if configured + */ + @Option.Configured + Optional memoryCache(); + + /** + * Temporary storage to use across all classpath handlers. + * If not defined, a default one will be created. + * + * @return temporary storage + */ + @Option.Configured + Optional temporaryStorage(); + + /** + * List of classpath based static content handlers. + * + * @return classpath handlers + */ + @Option.Configured + @Option.Singular + List classpath(); + + /** + * List of file system based static content handlers. + * + * @return path handlers + */ + @Option.Configured + @Option.Singular + List path(); + + /** + * Maps a filename extension to the response content type. + * To have a system-wide configuration, you can use the service loader SPI + * {@link io.helidon.common.media.type.spi.MediaTypeDetector}. + *

+ * This method can override {@link io.helidon.common.media.type.MediaTypes} detection + * for a specific static content handler. + *

+ * Handler will use a union of configuration defined here, and on the handler + * here when used from configuration. + * + * @return map of file extensions to associated media type + */ + @Option.Configured + @Option.Singular + Map contentTypes(); + + /** + * Welcome-file name. Default for all handlers. + * By default, we do not serve default files. + * + * @return welcome-file name, such as {@code index.html} + */ + @Option.Configured + Optional welcome(); + + /** + * Sockets names (listeners) that will host static content handlers, defaults to all configured sockets. + * Default socket name is {@code @default}. + *

+ * This configures defaults for all handlers. + * + * @return sockets to register this handler on + */ + @Option.Configured + @Option.Singular + Set sockets(); + +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigSupport.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigSupport.java new file mode 100644 index 00000000000..c01793f3901 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigSupport.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.nio.file.Path; + +import io.helidon.builder.api.Prototype; +import io.helidon.common.config.Config; +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; + +final class StaticContentConfigSupport { + private StaticContentConfigSupport() { + } + + static class BaseMethods { + private BaseMethods() { + } + + @Prototype.FactoryMethod + static MediaType createContentTypes(Config config) { + return StaticContentConfigSupport.createContentTypes(config); + } + } + + static class FileSystemMethods { + private FileSystemMethods() { + } + + /** + * Create a new file system based static content configuration from the defined location. + * All other configuration is default. + * + * @param location path on file system that is the root of static content (all files under it will be available!) + * @return a new configuration for classpath static content handler + */ + @Prototype.FactoryMethod + static FileSystemHandlerConfig create(Path location) { + return FileSystemHandlerConfig.builder() + .location(location) + .build(); + } + } + + static class ClasspathMethods { + private ClasspathMethods() { + } + + /** + * Create a new classpath based static content configuration from the defined location. + * All other configuration is default. + * + * @param location location on classpath + * @return a new configuration for classpath static content handler + */ + @Prototype.FactoryMethod + static ClasspathHandlerConfig create(String location) { + return ClasspathHandlerConfig.builder() + .location(location) + .build(); + } + } + + static class StaticContentMethods { + private StaticContentMethods() { + } + + @Prototype.FactoryMethod + static MediaType createContentTypes(Config config) { + return StaticContentConfigSupport.createContentTypes(config); + } + } + + private static MediaType createContentTypes(Config config) { + return config.asString() + .map(MediaTypes::create) + .get(); + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeature.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeature.java new file mode 100644 index 00000000000..cf83d2d102a --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeature.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.lang.System.Logger; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import io.helidon.builder.api.RuntimeType; +import io.helidon.common.Weighted; +import io.helidon.common.media.type.MediaType; +import io.helidon.config.Config; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.HttpService; +import io.helidon.webserver.spi.ServerFeature; + +/** + * WebServer feature to register static content. + */ +@RuntimeType.PrototypedBy(StaticContentConfig.class) +public class StaticContentFeature implements Weighted, ServerFeature, RuntimeType.Api { + static final String STATIC_CONTENT_ID = "static-content"; + static final double WEIGHT = 95; + + private static final Logger LOGGER = System.getLogger(StaticContentFeature.class.getName()); + + private final StaticContentConfig config; + private final MemoryCache memoryCache; + private final TemporaryStorage temporaryStorage; + private final Map contentTypeMapping; + private final boolean enabled; + private final Set sockets; + private final Optional welcome; + + private StaticContentFeature(StaticContentConfig config) { + this.config = config; + this.enabled = config.enabled() && !(config.classpath().isEmpty() && config.path().isEmpty()); + if (enabled) { + this.contentTypeMapping = config.contentTypes(); + this.memoryCache = config.memoryCache() + .orElseGet(MemoryCache::create); + this.sockets = config.sockets(); + this.welcome = config.welcome(); + + if (config.classpath().isEmpty()) { + this.temporaryStorage = null; + } else { + this.temporaryStorage = config.temporaryStorage() + .orElseGet(TemporaryStorage::create); + } + } else { + this.sockets = Set.of(); + this.welcome = Optional.empty(); + this.memoryCache = null; + this.temporaryStorage = null; + this.contentTypeMapping = null; + } + } + + /** + * Create Access log support configured from {@link io.helidon.config.Config}. + * + * @param config to configure a new access log support instance + * @return a new access log support to be registered with WebServer routing + */ + public static StaticContentFeature create(Config config) { + return builder() + .config(config) + .build(); + } + + /** + * A new fluent API builder to create Access log support instance. + * + * @return a new builder + */ + public static StaticContentConfig.Builder builder() { + return StaticContentConfig.builder(); + } + + /** + * Create a new instance from its configuration. + * + * @param config configuration + * @return a new feature + */ + public static StaticContentFeature create(StaticContentConfig config) { + return new StaticContentFeature(config); + } + + /** + * Create a new instance customizing its configuration. + * + * @param builderConsumer consumer of configuration + * @return a new feature + */ + public static StaticContentFeature create(Consumer builderConsumer) { + return builder() + .update(builderConsumer) + .build(); + } + + /** + * Create an Http service for file system based content handler. + * + * @param config configuration of the content handler + * @return a new HTTP service ready to be registered + */ + public static HttpService createService(FileSystemHandlerConfig config) { + return FileSystemContentHandler.create(config); + } + + /** + * Create an Http service for classpath based content handler. + * + * @param config configuration of the content handler + * @return a new HTTP service ready to be registered + */ + public static HttpService createService(ClasspathHandlerConfig config) { + return ClassPathContentHandler.create(config); + } + + @Override + public StaticContentConfig prototype() { + return config; + } + + @Override + public String name() { + return config.name(); + } + + @Override + public String type() { + return STATIC_CONTENT_ID; + } + + @Override + public void setup(ServerFeatureContext featureContext) { + if (!enabled) { + return; + } + + Set defaultSockets; + if (this.sockets.isEmpty()) { + defaultSockets = new HashSet<>(featureContext.sockets()); + defaultSockets.add(WebServer.DEFAULT_SOCKET_NAME); + } else { + defaultSockets = new HashSet<>(this.sockets); + } + + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + + for (ClasspathHandlerConfig handlerConfig : config.classpath()) { + if (!handlerConfig.enabled()) { + continue; + } + + + Set handlerSockets = handlerConfig.sockets().isEmpty() + ? defaultSockets + : handlerConfig.sockets(); + MemoryCache handlerCache = handlerConfig.memoryCache() + .orElse(this.memoryCache); + TemporaryStorage handlerTmpStorage = handlerConfig.temporaryStorage() + .orElse(this.temporaryStorage); + ClassLoader handlerClassLoader = handlerConfig.classLoader() + .orElse(contextClassLoader); + Optional welcome = handlerConfig.welcome().or(() -> this.welcome); + Map contentTypeMap = new HashMap<>(this.contentTypeMapping); + contentTypeMap.putAll(handlerConfig.contentTypes()); + + for (String handlerSocket : handlerSockets) { + if (!featureContext.socketExists(handlerSocket)) { + LOGGER.log(Logger.Level.WARNING, "Static content handler is configured for socket \"" + handlerSocket + + "\" that is not configured on the server"); + continue; + } + + handlerConfig = ClasspathHandlerConfig.builder() + .from(handlerConfig) + .memoryCache(handlerCache) + .temporaryStorage(handlerTmpStorage) + .update(it -> welcome.ifPresent(it::welcome)) + .classLoader(handlerClassLoader) + .contentTypes(contentTypeMap) + .build(); + + HttpService service = createService(handlerConfig); + featureContext.socket(handlerSocket) + .httpRouting() + .register(handlerConfig.context(), service); + } + } + for (FileSystemHandlerConfig handlerConfig : config.path()) { + if (!handlerConfig.enabled()) { + continue; + } + Set handlerSockets = handlerConfig.sockets().isEmpty() + ? defaultSockets + : handlerConfig.sockets(); + MemoryCache handlerCache = handlerConfig.memoryCache() + .orElse(this.memoryCache); + Map contentTypeMap = new HashMap<>(this.contentTypeMapping); + contentTypeMap.putAll(handlerConfig.contentTypes()); + + for (String handlerSocket : handlerSockets) { + if (!featureContext.socketExists(handlerSocket)) { + LOGGER.log(Logger.Level.WARNING, "Static content handler is configured for socket \"" + handlerSocket + + "\" that is not configured on the server"); + continue; + } + + handlerConfig = FileSystemHandlerConfig.builder() + .from(handlerConfig) + .memoryCache(handlerCache) + .contentTypes(contentTypeMap) + .build(); + + HttpService service = createService(handlerConfig); + featureContext.socket(handlerSocket) + .httpRouting() + .register(handlerConfig.context(), service); + } + } + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeatureProvider.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeatureProvider.java new file mode 100644 index 00000000000..fe9379a2f4f --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeatureProvider.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import io.helidon.common.Weight; +import io.helidon.common.config.Config; +import io.helidon.webserver.spi.ServerFeatureProvider; + +/** + * {@link java.util.ServiceLoader} provider implementation for static-content feature for {@link io.helidon.webserver.WebServer}. + */ +@Weight(StaticContentFeature.WEIGHT) +public class StaticContentFeatureProvider implements ServerFeatureProvider { + /** + * Required for {@link java.util.ServiceLoader}. + * + * @deprecated only for {@link java.util.ServiceLoader} + */ + @Deprecated + public StaticContentFeatureProvider() { + } + + @Override + public String configKey() { + return StaticContentFeature.STATIC_CONTENT_ID; + } + + @Override + public StaticContentFeature create(Config config, String name) { + return StaticContentFeature.builder() + .config(config) + .name(name) + .build(); + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java index 101ed81080f..b84e3d59a63 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java @@ -24,12 +24,11 @@ import java.time.ZonedDateTime; import java.time.chrono.ChronoZonedDateTime; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Function; +import java.util.function.Supplier; import io.helidon.common.configurable.LruCache; import io.helidon.common.media.type.MediaType; @@ -52,19 +51,23 @@ /** * Base implementation of static content support. */ +@SuppressWarnings("removal") // will be replaced with HttpService once removed, or made package local abstract class StaticContentHandler implements StaticContentService { private static final System.Logger LOGGER = System.getLogger(StaticContentHandler.class.getName()); - private final Map inMemoryCache = new ConcurrentHashMap<>(); private final LruCache handlerCache; private final String welcomeFilename; private final Function resolvePathFunction; private final AtomicInteger webServerCounter = new AtomicInteger(); - - StaticContentHandler(StaticContentService.Builder builder) { - this.welcomeFilename = builder.welcomeFileName(); - this.resolvePathFunction = builder.resolvePathFunction(); - this.handlerCache = builder.handlerCache(); + private final MemoryCache memoryCache; + + StaticContentHandler(BaseHandlerConfig config) { + this.welcomeFilename = config.welcome().orElse(null); + this.resolvePathFunction = config.pathMapper(); + this.handlerCache = LruCache.builder() + .update(it -> config.recordCacheCapacity().ifPresent(it::capacity)) + .build(); + this.memoryCache = config.memoryCache().orElseGet(MemoryCache::create); } /** @@ -172,6 +175,11 @@ static void throwNotFoundIf(boolean condition) { } } + static String formatLastModified(Instant lastModified) { + ZonedDateTime dt = ZonedDateTime.ofInstant(lastModified, ZoneId.systemDefault()); + return dt.format(DateTime.RFC_1123_DATE_TIME); + } + @Override public void beforeStart() { webServerCounter.incrementAndGet(); @@ -199,7 +207,7 @@ public void routing(HttpRules rules) { */ void releaseCache() { handlerCache.clear(); - inMemoryCache.clear(); + memoryCache.clear(this); } /** @@ -247,7 +255,7 @@ void handle(ServerRequest request, ServerResponse response) { * @param response an HTTP response * @param mapped whether the requestedPath is mapped using a mapping function (and differs from defined path) * @return {@code true} only if static content was found and processed. - * @throws java.io.IOException if resource is not acceptable + * @throws java.io.IOException if resource is not acceptable * @throws io.helidon.http.RequestException if some known WEB error */ abstract boolean doHandle(Method method, @@ -272,7 +280,7 @@ String welcomePageName() { * @param handler in memory handler */ void cacheInMemory(String resource, CachedHandlerInMemory handler) { - inMemoryCache.put(resource, handler); + memoryCache.cache(this, resource, handler); } /** @@ -282,7 +290,15 @@ void cacheInMemory(String resource, CachedHandlerInMemory handler) { * @return handler if found */ Optional cacheInMemory(String resource) { - return Optional.ofNullable(inMemoryCache.get(resource)); + return memoryCache.get(this, resource); + } + + boolean canCacheInMemory(int size) { + return memoryCache.available(size); + } + + Optional cacheInMemory(String resource, int size, Supplier supplier) { + return memoryCache.cache(this, resource, size, supplier); } /** @@ -306,19 +322,6 @@ LruCache handlerCache() { return handlerCache; } - private static String unquoteETag(String etag) { - if (etag == null || etag.isEmpty()) { - return etag; - } - if (etag.startsWith("W/") || etag.startsWith("w/")) { - etag = etag.substring(2); - } - if (etag.startsWith("\"") && etag.endsWith("\"")) { - etag = etag.substring(1, etag.length() - 1); - } - return etag; - } - void cacheInMemory(String resource, MediaType contentType, byte[] bytes, Optional lastModified) { int contentLength = bytes.length; Header contentLengthHeader = HeaderValues.create(HeaderNames.CONTENT_LENGTH, contentLength); @@ -349,8 +352,16 @@ void cacheInMemory(String resource, MediaType contentType, byte[] bytes, Optiona cacheInMemory(resource, inMemoryResource); } - static String formatLastModified(Instant lastModified) { - ZonedDateTime dt = ZonedDateTime.ofInstant(lastModified, ZoneId.systemDefault()); - return dt.format(DateTime.RFC_1123_DATE_TIME); + private static String unquoteETag(String etag) { + if (etag == null || etag.isEmpty()) { + return etag; + } + if (etag.startsWith("W/") || etag.startsWith("w/")) { + etag = etag.substring(2); + } + if (etag.startsWith("\"") && etag.endsWith("\"")) { + etag = etag.substring(1, etag.length() - 1); + } + return etag; } } diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentService.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentService.java index 872468578ab..e02fd89bb02 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentService.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2023 Oracle and/or its affiliates. + * Copyright (c) 2017, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,11 +21,11 @@ import java.util.HashSet; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.TreeMap; import java.util.function.Function; -import io.helidon.common.configurable.LruCache; import io.helidon.common.media.type.MediaType; import io.helidon.webserver.http.HttpService; @@ -42,7 +42,13 @@ * } *

* Content is served ONLY on HTTP {@code GET} method. + * + * @deprecated static content has been refactored to use server feature, with a new service, using new configuration approach, + * use {@link io.helidon.webserver.staticcontent.StaticContentFeature} instead, or if specific services are desired, + * kindly use {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(ClasspathHandlerConfig)} + * and/or {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(FileSystemHandlerConfig)} */ +@Deprecated(forRemoval = true, since = "4.1.5") public interface StaticContentService extends HttpService { /** * Creates new builder with defined static content root as a class-loader resource. Builder provides ability to define @@ -53,7 +59,9 @@ public interface StaticContentService extends HttpService { * @param resourceRoot a root resource path. * @return a builder * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + * @deprecated use {@link ClasspathHandlerConfig#builder()} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static ClassPathBuilder builder(String resourceRoot) { Objects.requireNonNull(resourceRoot, "Attribute resourceRoot is null!"); return builder(resourceRoot, Thread.currentThread().getContextClassLoader()); @@ -67,7 +75,9 @@ static ClassPathBuilder builder(String resourceRoot) { * @param classLoader a class-loader for the static content * @return a builder * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + * @deprecated use {@link ClasspathHandlerConfig#builder()} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static ClassPathBuilder builder(String resourceRoot, ClassLoader classLoader) { Objects.requireNonNull(resourceRoot, "Attribute resourceRoot is null!"); return new ClassPathBuilder() @@ -82,7 +92,9 @@ static ClassPathBuilder builder(String resourceRoot, ClassLoader classLoader) { * @param root a root path. * @return a builder * @throws NullPointerException if {@code root} attribute is {@code null} + * @deprecated use {@link io.helidon.webserver.staticcontent.FileSystemHandlerConfig#builder()} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static FileSystemBuilder builder(Path root) { Objects.requireNonNull(root, "Attribute root is null!"); return new FileSystemBuilder() @@ -97,7 +109,10 @@ static FileSystemBuilder builder(Path root) { * @param resourceRoot a root resource path. * @return created instance * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + * @deprecated use {@link ClasspathHandlerConfig#builder()} and + * {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(ClasspathHandlerConfig)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static StaticContentService create(String resourceRoot) { return create(resourceRoot, Thread.currentThread().getContextClassLoader()); } @@ -109,7 +124,10 @@ static StaticContentService create(String resourceRoot) { * @param classLoader a class-loader for the static content * @return created instance * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + * @deprecated use {@link ClasspathHandlerConfig#builder()} and + * {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(ClasspathHandlerConfig)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static StaticContentService create(String resourceRoot, ClassLoader classLoader) { return builder(resourceRoot, classLoader).build(); } @@ -120,7 +138,10 @@ static StaticContentService create(String resourceRoot, ClassLoader classLoader) * @param root a root path. * @return created instance * @throws NullPointerException if {@code root} attribute is {@code null} + * @deprecated use {@link io.helidon.webserver.staticcontent.FileSystemHandlerConfig#builder()} and + * {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(FileSystemHandlerConfig)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static StaticContentService create(Path root) { return builder(root).build(); } @@ -129,13 +150,16 @@ static StaticContentService create(Path root) { * Fluent builder of the StaticContent detailed parameters. * * @param type of a subclass of a concrete builder + * @deprecated replaced with {@link io.helidon.webserver.staticcontent.FileSystemHandlerConfig} and + * {@link io.helidon.webserver.staticcontent.ClasspathHandlerConfig} */ + @Deprecated(forRemoval = true, since = "4.1.5") abstract class Builder> implements io.helidon.common.Builder { + private final Set cacheInMemory = new HashSet<>(); + private String welcomeFileName; private Function resolvePathFunction = Function.identity(); - private Set cacheInMemory = new HashSet<>(); - private LruCache handlerCache; - + private Integer recordCacheCapacity; /** * Default constructor. @@ -205,13 +229,10 @@ public B addCacheInMemory(String path) { * @return updated builder */ public B recordCacheCapacity(int capacity) { - this.handlerCache = LruCache.builder() - .capacity(capacity) - .build(); + this.recordCacheCapacity = capacity; return identity(); } - /** * Build the actual instance. * @@ -231,9 +252,8 @@ Set cacheInMemory() { return cacheInMemory; } - - LruCache handlerCache() { - return handlerCache == null ? LruCache.create() : handlerCache; + Optional handlerCacheCapacity() { + return Optional.ofNullable(recordCacheCapacity); } } @@ -245,6 +265,7 @@ LruCache handlerCache() { @SuppressWarnings("unchecked") abstract class FileBasedBuilder> extends Builder> { private final Map specificContentTypes = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + /** * Default constructor. */ @@ -311,7 +332,25 @@ public ClassPathBuilder tmpDir(Path tmpDir) { @Override protected StaticContentService doBuild() { - return new ClassPathContentHandler(this); + return ClassPathContentHandler.create( + ClasspathHandlerConfig.builder() + .location(clRoot) + .contentTypes(specificContentTypes()) + .update(it -> { + if (welcomeFileName() != null) { + it.welcome(welcomeFileName()); + } + }) + .cachedFiles(cacheInMemory()) + .pathMapper(resolvePathFunction()) + .update(it -> handlerCacheCapacity().ifPresent(it::recordCacheCapacity)) + .update(it -> classLoader().ifPresent(it::classLoader)) + .update(it -> { + if (tmpDir != null) { + it.temporaryStorage(tmp -> tmp.directory(tmpDir)); + } + }) + .build()); } ClassPathBuilder classLoader(ClassLoader cl) { @@ -338,16 +377,8 @@ ClassPathBuilder root(String root) { return this; } - String root() { - return clRoot; - } - - ClassLoader classLoader() { - return classLoader; - } - - Path tmpDir() { - return tmpDir; + Optional classLoader() { + return Optional.ofNullable(classLoader); } } @@ -368,11 +399,19 @@ protected StaticContentService doBuild() { if (root == null) { throw new NullPointerException("Root path must be defined"); } - if (Files.isDirectory(root)) { - return new FileSystemContentHandler(this); - } else { - return new SingleFileContentHandler(this); - } + return FileSystemContentHandler.create( + FileSystemHandlerConfig.builder() + .location(root) + .contentTypes(specificContentTypes()) + .update(it -> { + if (welcomeFileName() != null) { + it.welcome(welcomeFileName()); + } + }) + .cachedFiles(cacheInMemory()) + .pathMapper(resolvePathFunction()) + .update(it -> handlerCacheCapacity().ifPresent(it::recordCacheCapacity)) + .build()); } FileSystemBuilder root(Path root) { diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorage.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorage.java new file mode 100644 index 00000000000..30c158fc9c3 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorage.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Consumer; + +import io.helidon.builder.api.RuntimeType; + +/** + * Handling of temporary files. + */ +@RuntimeType.PrototypedBy(TemporaryStorageConfig.class) +public interface TemporaryStorage extends RuntimeType.Api { + /** + * Create a new builder. + * + * @return a new fluent API builder + */ + static TemporaryStorageConfig.Builder builder() { + return TemporaryStorageConfig.builder(); + } + + /** + * Create a new instance from its configuration. + * + * @param config configuration of temporary storage + * @return a new configured instance + */ + static TemporaryStorage create(TemporaryStorageConfig config) { + return new TemporaryStorageImpl(config); + } + + /** + * Create a new instance customizing its configuration. + * + * @param consumer consumer of configuration of temporary storage + * @return a new configured instance + */ + static TemporaryStorage create(Consumer consumer) { + return builder().update(consumer).build(); + } + + /** + * Create a new instance with defaults. + * + * @return a new temporary storage (enabled) + */ + static TemporaryStorage create() { + return builder().build(); + } + + /** + * Create a temporary file. + * + * @return a new temporary file, if enabled and successful + */ + Optional createFile(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageConfigBlueprint.java new file mode 100644 index 00000000000..b3c1a3fdf41 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageConfigBlueprint.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.nio.file.Path; +import java.util.Optional; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +/** + * Configuration of temporary storage for classpath based handlers. + */ +@Prototype.Configured +@Prototype.Blueprint +interface TemporaryStorageConfigBlueprint extends Prototype.Factory { + /** + * Default prefix. + */ + String DEFAULT_FILE_PREFIX = "helidon-ws"; + /** + * Default suffix. + */ + String DEFAULT_FILE_SUFFIX = ".je"; + + /** + * Whether the temporary storage is enabled, defaults to {@code true}. + * If disabled, nothing is stored in temporary directory (may have performance impact, as for example a file may be + * extracted from a zip file on each request). + * + * @return whether the temporary storage is enabled + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean enabled(); + + /** + * Location of the temporary storage, defaults to temporary storage configured for the JVM. + * + * @return directory of temporary storage + */ + @Option.Configured + Optional directory(); + + /** + * Prefix of the files in temporary storage. + * + * @return file prefix + */ + @Option.Configured + @Option.Default(DEFAULT_FILE_PREFIX) + String filePrefix(); + + /** + * Suffix of the files in temporary storage. + * + * @return file suffix + */ + @Option.Configured + @Option.Default(DEFAULT_FILE_SUFFIX) + String fileSuffix(); + + /** + * Whether temporary files should be deleted on JVM exit. + * This is enabled by default, yet it may be useful for debugging purposes to keep the files in place. + * + * @return whether to delete temporary files on JVM exit + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean deleteOnExit(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageImpl.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageImpl.java new file mode 100644 index 00000000000..0878f2fbb7e --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageImpl.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +import io.helidon.Main; +import io.helidon.spi.HelidonShutdownHandler; + +class TemporaryStorageImpl implements TemporaryStorage { + private static final System.Logger LOGGER = System.getLogger(TemporaryStorage.class.getName()); + + private final TemporaryStorageConfig config; + private final Supplier> tmpFile; + + TemporaryStorageImpl(TemporaryStorageConfig config) { + this.config = config; + this.tmpFile = tempFileSupplier(config); + } + + @Override + public TemporaryStorageConfig prototype() { + return config; + } + + @Override + public Optional createFile() { + return tmpFile.get(); + } + + private static Supplier> tempFileSupplier(TemporaryStorageConfig config) { + if (!config.enabled()) { + return Optional::empty; + } + + DeleteFilesHandler deleteFilesHandler = new DeleteFilesHandler(); + if (config.deleteOnExit()) { + Main.addShutdownHandler(deleteFilesHandler); + } + var configuredDir = config.directory(); + + IoSupplier pathSupplier; + if (configuredDir.isPresent()) { + pathSupplier = () -> Files.createTempFile(configuredDir.get(), config.filePrefix(), config.fileSuffix()); + } else { + pathSupplier = () -> Files.createTempFile(config.filePrefix(), config.fileSuffix()); + } + + return () -> { + deleteFilesHandler.tempFilesLock.lock(); + try { + if (deleteFilesHandler.closed) { + // we are shutting down, cannot provide a temp file, as we would not delete it + return Optional.empty(); + } + + Path path = pathSupplier.get(); + deleteFilesHandler.tempFiles.add(path); + return Optional.of(path); + } catch (IOException e) { + LOGGER.log(System.Logger.Level.WARNING, "Failed to create temporary file. Config: " + config, e); + return Optional.empty(); + } finally { + deleteFilesHandler.tempFilesLock.unlock(); + } + }; + } + + private static class DeleteFilesHandler implements HelidonShutdownHandler { + + private final List tempFiles = new ArrayList<>(); + private final ReentrantLock tempFilesLock = new ReentrantLock(); + + private volatile boolean closed; + + @Override + public void shutdown() { + tempFilesLock.lock(); + try { + closed = true; + for (Path tempFile : tempFiles) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + LOGGER.log(System.Logger.Level.WARNING, + "Failed to delete temporary file: " + tempFile.toAbsolutePath(), + e); + } + } + + tempFiles.clear(); + } finally { + tempFilesLock.unlock(); + } + } + } +} diff --git a/webserver/static-content/src/main/java/module-info.java b/webserver/static-content/src/main/java/module-info.java index 0555221617c..1121770b86f 100644 --- a/webserver/static-content/src/main/java/module-info.java +++ b/webserver/static-content/src/main/java/module-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,7 +31,11 @@ requires transitive io.helidon.common.configurable; requires transitive io.helidon.webserver; + requires transitive io.helidon.builder.api; + requires io.helidon; exports io.helidon.webserver.staticcontent; + provides io.helidon.webserver.spi.ServerFeatureProvider + with io.helidon.webserver.staticcontent.StaticContentFeatureProvider; } \ No newline at end of file diff --git a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/CachedHandlerTest.java b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/CachedHandlerTest.java index a8b48feb129..718aa19ed9f 100644 --- a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/CachedHandlerTest.java +++ b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/CachedHandlerTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,6 +49,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@SuppressWarnings("removal") class CachedHandlerTest { private static final MediaType MEDIA_TYPE_ICON = MediaTypes.create("image/x-icon"); private static final Header ICON_TYPE = HeaderValues.create(HeaderNames.CONTENT_TYPE, MEDIA_TYPE_ICON.text()); diff --git a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentConfigTest.java b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentConfigTest.java new file mode 100644 index 00000000000..5543141dd2d --- /dev/null +++ b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentConfigTest.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.staticcontent; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; + +import io.helidon.common.testing.http.junit5.HttpHeaderMatcher; +import io.helidon.common.testing.junit5.OptionalMatcher; +import io.helidon.config.Config; +import io.helidon.config.ConfigSources; +import io.helidon.http.HeaderNames; +import io.helidon.http.Status; +import io.helidon.webclient.api.ClientResponseTyped; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webclient.http1.Http1ClientResponse; +import io.helidon.webserver.WebServer; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.endsWith; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.MatcherAssert.assertThat; + +class StaticContentConfigTest { + private static Http1Client testClient; + private static WebServer server; + private static Path tmpPath; + + @BeforeAll + static void setUp() throws IOException { + // current directory + Path path = Paths.get("."); + path = path.resolve("target/helidon/tmp"); + // we need to have this file ready + Files.createDirectories(path); + tmpPath = path; + + Config config = Config.just(ConfigSources.classpath("/config-unit-test-1.yaml")); + server = WebServer.builder() + .config(config.get("server")) + .port(0) + .build() + .start(); + + testClient = Http1Client.builder() + .baseUri("http://localhost:" + server.port()) + .shareConnectionCache(false) + .build(); + } + + @AfterAll + static void tearDown() { + if (server != null) { + server.stop(); + } + } + + @Test + void testClasspathFavicon() { + try (Http1ClientResponse response = testClient.get("/classpath/favicon.ico") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "image/x-icon")); + } + } + + @Test + void testClasspathNested() { + try (Http1ClientResponse response = testClient.get("/classpath/nested/resource.txt") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Nested content")); + } + } + + @Test + void testClasspathFromJar() throws IOException { + String serviceName = "io.helidon.webserver.testing.junit5.spi.ServerJunitExtension"; + ClientResponseTyped response = testClient.get("/jar/" + serviceName) + .request(String.class); + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "application/octet-stream")); + assertThat(response.entity(), startsWith("# This file was generated by Helidon services Maven plugin.")); + + // when run in maven, we have jar, but from IDE we get a file, so we have to check it correctly + URL resource = Thread.currentThread().getContextClassLoader().getResource("META-INF/services/" + serviceName); + assertThat(resource, notNullValue()); + if (resource.getProtocol().equals("jar")) { + // we can validate the temporary file exists and is correct + try (var stream = Files.list(tmpPath)) { + Optional tmpFile = stream.findAny(); + + assertThat("There should be a single temporary file created in " + tmpPath, + tmpFile, + OptionalMatcher.optionalPresent()); + String fileName = tmpFile.get().getFileName().toString(); + assertThat(fileName, startsWith("helidon-custom")); + assertThat(fileName, endsWith(".cache")); + } + } + } + + @Test + void testClasspathSingleFile() { + try (Http1ClientResponse response = testClient.get("/singleclasspath") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Content")); + } + } + + @Test + void testFileSystemFavicon() { + try (Http1ClientResponse response = testClient.get("/path/favicon.ico") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "image/my-icon")); + } + } + + @Test + void testFileSystemNested() { + try (Http1ClientResponse response = testClient.get("/path/nested/resource.txt") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Nested content")); + } + } + + @Test + void testFileSystemSingleFile() { + try (Http1ClientResponse response = testClient.get("/singlepath") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Content")); + } + } +} diff --git a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java index 59b62d77116..56ec5f000d9 100644 --- a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java +++ b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java @@ -279,13 +279,15 @@ static class TestContentHandler extends FileSystemContentHandler { final boolean returnValue; Path path; - TestContentHandler(StaticContentService.FileSystemBuilder builder, boolean returnValue) { - super(builder); + TestContentHandler(FileSystemHandlerConfig config, boolean returnValue) { + super(config); this.returnValue = returnValue; } static TestContentHandler create(boolean returnValue) { - return new TestContentHandler(StaticContentService.builder(Paths.get(".")), returnValue); + return new TestContentHandler(FileSystemHandlerConfig.builder() + .location(Paths.get(".")) + .build(), returnValue); } @Override @@ -307,13 +309,15 @@ static class TestClassPathContentHandler extends ClassPathContentHandler { final AtomicInteger counter = new AtomicInteger(0); final boolean returnValue; - TestClassPathContentHandler(StaticContentService.ClassPathBuilder builder, boolean returnValue) { - super(builder); + TestClassPathContentHandler(ClasspathHandlerConfig config, boolean returnValue) { + super(config); this.returnValue = returnValue; } static TestClassPathContentHandler create() { - return new TestClassPathContentHandler(StaticContentService.builder("/root"), true); + return new TestClassPathContentHandler(ClasspathHandlerConfig.builder() + .location("/root") + .build(), true); } @Override diff --git a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentTest.java b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentTest.java index 59ea7b2084d..907514b35b3 100644 --- a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentTest.java +++ b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, 2023 Oracle and/or its affiliates. + * Copyright (c) 2021, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import static io.helidon.webserver.staticcontent.StaticContentFeature.createService; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -45,6 +46,7 @@ class StaticContentTest { this.testClient = testClient; } + @SuppressWarnings("removal") @SetUpRoute static void setupRouting(HttpRouting.Builder builder) throws Exception { Path nested = tempDir.resolve("nested"); @@ -57,10 +59,15 @@ static void setupRouting(HttpRouting.Builder builder) throws Exception { Files.writeString(favicon, "Wrong icon text"); Files.writeString(nested.resolve("resource.txt"), "Nested content"); - builder.register("/classpath", StaticContentService.builder("web")) - .register("/singleclasspath", StaticContentService.builder("web/resource.txt")) - .register("/path", StaticContentService.builder(tempDir)) - .register("/singlepath", StaticContentService.builder(resource)); + builder.register("/classpath", createService(ClasspathHandlerConfig.create("web"))) + .register("/singleclasspath", createService(ClasspathHandlerConfig.create("web/resource.txt"))) + .register("/path", createService(FileSystemHandlerConfig.create(tempDir))) + .register("/singlepath", createService(FileSystemHandlerConfig.create(resource))); + + builder.register("/backward-comp/classpath", StaticContentService.builder("web")) + .register("/backward-comp/singleclasspath", StaticContentService.builder("web/resource.txt")) + .register("/backward-comp/path", StaticContentService.builder(tempDir)) + .register("/backward-comp/singlepath", StaticContentService.builder(resource)); } @Test @@ -73,6 +80,16 @@ void testClasspathFavicon() { } } + @Test + void testClasspathFaviconBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/classpath/favicon.ico") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "image/x-icon")); + } + } + @Test void testClasspathNested() { try (Http1ClientResponse response = testClient.get("/classpath/nested/resource.txt") @@ -84,6 +101,17 @@ void testClasspathNested() { } } + @Test + void testClasspathNestedBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/classpath/nested/resource.txt") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Nested content")); + } + } + @Test void testClasspathSingleFile() { try (Http1ClientResponse response = testClient.get("/singleclasspath") @@ -95,6 +123,17 @@ void testClasspathSingleFile() { } } + @Test + void testClasspathSingleFileBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/singleclasspath") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Content")); + } + } + @Test void testFileSystemFavicon() { try (Http1ClientResponse response = testClient.get("/path/favicon.ico") @@ -105,6 +144,16 @@ void testFileSystemFavicon() { } } + @Test + void testFileSystemFaviconBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/path/favicon.ico") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "image/x-icon")); + } + } + @Test void testFileSystemNested() { try (Http1ClientResponse response = testClient.get("/path/nested/resource.txt") @@ -116,6 +165,18 @@ void testFileSystemNested() { } } + + @Test + void testFileSystemNestedBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/path/nested/resource.txt") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Nested content")); + } + } + @Test void testFileSystemSingleFile() { try (Http1ClientResponse response = testClient.get("/singlepath") @@ -126,4 +187,15 @@ void testFileSystemSingleFile() { assertThat(response.as(String.class), is("Content")); } } + + @Test + void testFileSystemSingleFileBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/singlepath") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Content")); + } + } } diff --git a/webserver/static-content/src/test/resources/config-unit-test-1.yaml b/webserver/static-content/src/test/resources/config-unit-test-1.yaml new file mode 100644 index 00000000000..e9be224c63f --- /dev/null +++ b/webserver/static-content/src/test/resources/config-unit-test-1.yaml @@ -0,0 +1,42 @@ +# +# Copyright (c) 2024 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Static content configuration + +server: + features: + static-content: + temporary-storage: + directory: "./target/helidon/tmp" + file-prefix: "helidon-custom" + file-suffix: ".cache" + # delete-on-exit: false + content-types: + ico: "image/my-icon" + classpath: + - context: "/classpath" + location: "web" + content-types: + ico: "image/x-icon" + - context: "/singleclasspath" + location: "web/resource.txt" + - context: "/jar" + location: "META-INF/services" + path: + - context: "/path" + location: "./src/test/resources/web" + - context: "/singlepath" + location: "./src/test/resources/web/resource.txt" diff --git a/webserver/static-content/src/test/resources/logging-test.properties b/webserver/static-content/src/test/resources/logging-test.properties new file mode 100644 index 00000000000..fd7ff767340 --- /dev/null +++ b/webserver/static-content/src/test/resources/logging-test.properties @@ -0,0 +1,19 @@ +# +# Copyright (c) 2024 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +handlers=io.helidon.logging.jul.HelidonConsoleHandler +java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS %4$s %3$s !thread!: %5$s%6$s%n +.level=INFO