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 775878131ab..c67e0d0ef6f 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 @@ -34,6 +34,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; @@ -220,8 +221,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 97b61cc9da1..e923e95a61d 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; @@ -86,4 +87,35 @@ public static String validateDuration(TypeName enclosingType, element.originatingElementValue()); } } + + /** + * 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 3f392587ee7..b3d513c516e 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 @@ -30,6 +30,7 @@ import io.helidon.common.Generated; import io.helidon.common.GenericType; +import io.helidon.common.Size; /** * Commonly used type names. @@ -206,6 +207,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 4ffa8e89a19..ce431215bec 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