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