001/* 002 * Copyright 2022-2025 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import javax.annotation.Nonnull; 020import javax.annotation.Nullable; 021import javax.annotation.concurrent.NotThreadSafe; 022import javax.annotation.concurrent.ThreadSafe; 023import java.nio.charset.Charset; 024import java.nio.charset.StandardCharsets; 025import java.util.Objects; 026import java.util.Optional; 027import java.util.concurrent.locks.ReentrantLock; 028 029import static com.soklet.Utilities.trimAggressivelyToNull; 030import static java.lang.String.format; 031import static java.util.Objects.requireNonNull; 032 033/** 034 * Encapsulates an HTML form element name, binary and {@link String} representations of its value, and other attributes as encoded according to the <a href="https://datatracker.ietf.org/doc/html/rfc7578">{@code multipart/form-data}</a> specification. 035 * <p> 036 * Instances can be acquired via these builder factory methods: 037 * <ul> 038 * <li>{@link #withName(String)} (with form element name)</li> 039 * <li>{@link #with(String, byte[])} (with form element name and value)</li> 040 * </ul> 041 * <p> 042 * Full documentation is available at <a href="https://www.soklet.com/docs/request-handling#multipart-form-data">https://www.soklet.com/docs/request-handling#multipart-form-data</a>. 043 * 044 * @author <a href="https://www.revetkn.com">Mark Allen</a> 045 */ 046@ThreadSafe 047public final class MultipartField { 048 @Nonnull 049 private static final Charset DEFAULT_CHARSET; 050 051 static { 052 DEFAULT_CHARSET = StandardCharsets.UTF_8; 053 } 054 055 @Nonnull 056 private final String name; 057 @Nullable 058 private final byte[] data; 059 @Nullable 060 private String dataAsString; 061 @Nullable 062 private final String filename; 063 @Nullable 064 private final String contentType; 065 @Nullable 066 private final Charset charset; 067 @Nonnull 068 private final ReentrantLock lock; 069 070 /** 071 * Acquires a builder for {@link MultipartField} instances. 072 * 073 * @param name the name of this field 074 * @return the builder 075 */ 076 @Nonnull 077 public static Builder withName(@Nonnull String name) { 078 requireNonNull(name); 079 return new Builder(name); 080 } 081 082 /** 083 * Acquires a builder for {@link MultipartField} instances. 084 * 085 * @param name the name of this field 086 * @param value the optional value for this field 087 * @return the builder 088 */ 089 @Nonnull 090 public static Builder with(@Nonnull String name, 091 @Nullable byte[] value) { 092 requireNonNull(name); 093 return new Builder(name, value); 094 } 095 096 /** 097 * Vends a mutable copier seeded with this instance's data, suitable for building new instances. 098 * 099 * @return a copier for this instance 100 */ 101 @Nonnull 102 public Copier copy() { 103 return new Copier(this); 104 } 105 106 protected MultipartField(@Nonnull Builder builder) { 107 requireNonNull(builder); 108 109 String name = trimAggressivelyToNull(builder.name); 110 String filename = trimAggressivelyToNull(builder.filename); 111 String contentType = trimAggressivelyToNull(builder.contentType); 112 byte[] data = builder.data == null ? null : (builder.data.length == 0 ? null : builder.data); 113 114 if (name == null) 115 throw new IllegalArgumentException("Multipart field name is required"); 116 117 this.name = name; 118 this.filename = filename; 119 this.contentType = contentType; 120 this.charset = builder.charset; 121 this.data = data; 122 this.lock = new ReentrantLock(); 123 } 124 125 @Override 126 public String toString() { 127 return format("%s{name=%s, filename=%s, contentType=%s, data=%s}", 128 getClass().getSimpleName(), getName(), 129 getFilename().orElse("[not available]"), 130 getContentType().orElse("[not available]"), 131 (getData().isPresent() 132 ? (getFilename().isPresent() ? format("[%d bytes]", getData().get().length) : getDataAsString().orElse("[not available]")) 133 : "[not available]")); 134 } 135 136 @Override 137 public boolean equals(@Nullable Object object) { 138 if (this == object) 139 return true; 140 141 if (!(object instanceof MultipartField multipartField)) 142 return false; 143 144 return Objects.equals(getName(), multipartField.getName()) 145 && Objects.equals(getFilename(), multipartField.getFilename()) 146 && Objects.equals(getContentType(), multipartField.getContentType()) 147 && Objects.equals(getCharset(), multipartField.getCharset()) 148 && Objects.equals(getData(), multipartField.getData()); 149 } 150 151 @Override 152 public int hashCode() { 153 return Objects.hash(getName(), getFilename(), getContentType(), getCharset(), getData()); 154 } 155 156 /** 157 * Builder used to construct instances of {@link MultipartField} via {@link MultipartField#withName(String)} 158 * or {@link MultipartField#with(String, byte[])}. 159 * <p> 160 * This class is intended for use by a single thread. 161 * 162 * @author <a href="https://www.revetkn.com">Mark Allen</a> 163 */ 164 @NotThreadSafe 165 public static final class Builder { 166 @Nonnull 167 private String name; 168 @Nullable 169 private byte[] data; 170 @Nullable 171 private String filename; 172 @Nullable 173 private String contentType; 174 @Nullable 175 private Charset charset; 176 177 protected Builder(@Nonnull String name) { 178 this(name, null); 179 } 180 181 protected Builder(@Nonnull String name, 182 @Nullable byte[] data) { 183 requireNonNull(name); 184 requireNonNull(data); 185 186 this.name = name; 187 this.data = data; 188 } 189 190 @Nonnull 191 public Builder name(@Nonnull String name) { 192 requireNonNull(name); 193 this.name = name; 194 return this; 195 } 196 197 @Nonnull 198 public Builder data(@Nullable byte[] data) { 199 this.data = data; 200 return this; 201 } 202 203 @Nonnull 204 public Builder filename(@Nullable String filename) { 205 this.filename = filename; 206 return this; 207 } 208 209 @Nonnull 210 public Builder contentType(@Nullable String contentType) { 211 this.contentType = contentType; 212 return this; 213 } 214 215 @Nonnull 216 public Builder charset(@Nullable Charset charset) { 217 this.charset = charset; 218 return this; 219 } 220 221 @Nonnull 222 public MultipartField build() { 223 return new MultipartField(this); 224 } 225 } 226 227 /** 228 * Builder used to copy instances of {@link MultipartField} via {@link MultipartField#copy()}. 229 * <p> 230 * This class is intended for use by a single thread. 231 * 232 * @author <a href="https://www.revetkn.com">Mark Allen</a> 233 */ 234 @NotThreadSafe 235 public static final class Copier { 236 @Nonnull 237 private final Builder builder; 238 239 Copier(@Nonnull MultipartField multipartField) { 240 requireNonNull(multipartField); 241 242 this.builder = new Builder(multipartField.getName(), multipartField.getData().orElse(null)) 243 .filename(multipartField.getFilename().orElse(null)) 244 .contentType(multipartField.getContentType().orElse(null)) 245 .charset(multipartField.getCharset().orElse(null)); 246 } 247 248 @Nonnull 249 public Copier name(@Nonnull String name) { 250 requireNonNull(name); 251 this.builder.name(name); 252 return this; 253 } 254 255 @Nonnull 256 public Copier data(@Nullable byte[] data) { 257 this.builder.data(data); 258 return this; 259 } 260 261 @Nonnull 262 public Copier filename(@Nullable String filename) { 263 this.builder.filename(filename); 264 return this; 265 } 266 267 @Nonnull 268 public Copier contentType(@Nullable String contentType) { 269 this.builder.contentType(contentType); 270 return this; 271 } 272 273 @Nonnull 274 public Copier contentType(@Nullable Charset charset) { 275 this.builder.charset(charset); 276 return this; 277 } 278 279 @Nonnull 280 public MultipartField finish() { 281 return this.builder.build(); 282 } 283 } 284 285 /** 286 * The value of this field represented as a string, if available. 287 * 288 * @return the string value, or {@link Optional#empty()} if not available 289 */ 290 @Nonnull 291 public Optional<String> getDataAsString() { 292 // Lazily instantiate a string instance using double-checked locking 293 if (this.data != null && this.dataAsString == null) { 294 getLock().lock(); 295 try { 296 if (this.data != null && this.dataAsString == null) 297 this.dataAsString = new String(this.data, getCharset().orElse(DEFAULT_CHARSET)); 298 } finally { 299 getLock().unlock(); 300 } 301 } 302 303 return Optional.ofNullable(this.dataAsString); 304 } 305 306 /** 307 * The name of this field. 308 * 309 * @return the name of this field 310 */ 311 @Nonnull 312 public String getName() { 313 return this.name; 314 } 315 316 /** 317 * The filename associated with this field, if available. 318 * 319 * @return the filename, or {@link Optional#empty()} if not available 320 */ 321 @Nonnull 322 public Optional<String> getFilename() { 323 return Optional.ofNullable(this.filename); 324 } 325 326 /** 327 * The content type for this field, if available (for example, {@code image/png} for an image file). 328 * 329 * @return the content type, or {@link Optional#empty()} if not available 330 */ 331 @Nonnull 332 public Optional<String> getContentType() { 333 return Optional.ofNullable(this.contentType); 334 } 335 336 /** 337 * The charset used to encode this field, if applicable. 338 * 339 * @return the charset, or {@link Optional#empty()} if not available 340 */ 341 @Nonnull 342 public Optional<Charset> getCharset() { 343 return Optional.ofNullable(this.charset); 344 } 345 346 /** 347 * The binary value of this field, if available. 348 * 349 * @return the binary value, or {@link Optional#empty()} if not available 350 */ 351 @Nonnull 352 public Optional<byte[]> getData() { 353 return Optional.ofNullable(this.data); 354 } 355 356 @Nonnull 357 protected ReentrantLock getLock() { 358 return this.lock; 359 } 360}