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 private final byte @Nullable [] 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 byte @Nullable [] 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 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 private byte @Nullable [] data; 169 @Nullable 170 private String filename; 171 @Nullable 172 private String contentType; 173 @Nullable 174 private Charset charset; 175 176 Builder(@NonNull String name) { 177 this(name, null); 178 } 179 180 Builder(@NonNull String name, 181 byte @Nullable [] data) { 182 requireNonNull(name); 183 184 this.name = name; 185 this.data = data; 186 } 187 188 @NonNull 189 public Builder name(@NonNull String name) { 190 requireNonNull(name); 191 this.name = name; 192 return this; 193 } 194 195 @NonNull 196 public Builder data(byte @Nullable [] data) { 197 this.data = data; 198 return this; 199 } 200 201 @NonNull 202 public Builder filename(@Nullable String filename) { 203 this.filename = filename; 204 return this; 205 } 206 207 @NonNull 208 public Builder contentType(@Nullable String contentType) { 209 this.contentType = contentType; 210 return this; 211 } 212 213 @NonNull 214 public Builder charset(@Nullable Charset charset) { 215 this.charset = charset; 216 return this; 217 } 218 219 @NonNull 220 public MultipartField build() { 221 return new MultipartField(this); 222 } 223 } 224 225 /** 226 * Builder used to copy instances of {@link MultipartField} via {@link MultipartField#copy()}. 227 * <p> 228 * This class is intended for use by a single thread. 229 * 230 * @author <a href="https://www.revetkn.com">Mark Allen</a> 231 */ 232 @NotThreadSafe 233 public static final class Copier { 234 @NonNull 235 private final Builder builder; 236 237 Copier(@NonNull MultipartField multipartField) { 238 requireNonNull(multipartField); 239 240 this.builder = new Builder(multipartField.getName(), multipartField.getData().orElse(null)) 241 .filename(multipartField.getFilename().orElse(null)) 242 .contentType(multipartField.getContentType().orElse(null)) 243 .charset(multipartField.getCharset().orElse(null)); 244 } 245 246 @NonNull 247 public Copier name(@NonNull String name) { 248 requireNonNull(name); 249 this.builder.name(name); 250 return this; 251 } 252 253 @NonNull 254 public Copier data(byte @Nullable [] data) { 255 this.builder.data(data); 256 return this; 257 } 258 259 @NonNull 260 public Copier filename(@Nullable String filename) { 261 this.builder.filename(filename); 262 return this; 263 } 264 265 @NonNull 266 public Copier contentType(@Nullable String contentType) { 267 this.builder.contentType(contentType); 268 return this; 269 } 270 271 @NonNull 272 public Copier contentType(@Nullable Charset charset) { 273 this.builder.charset(charset); 274 return this; 275 } 276 277 @NonNull 278 public MultipartField finish() { 279 return this.builder.build(); 280 } 281 } 282 283 /** 284 * The value of this field represented as a string, if available. 285 * 286 * @return the string value, or {@link Optional#empty()} if not available 287 */ 288 @NonNull 289 public Optional<String> getDataAsString() { 290 // Lazily instantiate a string instance using double-checked locking 291 if (this.data != null && this.dataAsString == null) { 292 getLock().lock(); 293 try { 294 if (this.dataAsString == null) 295 this.dataAsString = new String(this.data, getCharset().orElse(DEFAULT_CHARSET)); 296 } finally { 297 getLock().unlock(); 298 } 299 } 300 301 return Optional.ofNullable(this.dataAsString); 302 } 303 304 /** 305 * The name of this field. 306 * 307 * @return the name of this field 308 */ 309 @NonNull 310 public String getName() { 311 return this.name; 312 } 313 314 /** 315 * The filename associated with this field, if available. 316 * 317 * @return the filename, or {@link Optional#empty()} if not available 318 */ 319 @NonNull 320 public Optional<String> getFilename() { 321 return Optional.ofNullable(this.filename); 322 } 323 324 /** 325 * The content type for this field, if available (for example, {@code image/png} for an image file). 326 * 327 * @return the content type, or {@link Optional#empty()} if not available 328 */ 329 @NonNull 330 public Optional<String> getContentType() { 331 return Optional.ofNullable(this.contentType); 332 } 333 334 /** 335 * The charset used to encode this field, if applicable. 336 * 337 * @return the charset, or {@link Optional#empty()} if not available 338 */ 339 @NonNull 340 public Optional<Charset> getCharset() { 341 return Optional.ofNullable(this.charset); 342 } 343 344 /** 345 * The binary value of this field, if available. 346 * 347 * @return the binary value, or {@link Optional#empty()} if not available 348 */ 349 @NonNull 350 public Optional<byte[]> getData() { 351 return Optional.ofNullable(this.data); 352 } 353 354 @NonNull 355 protected ReentrantLock getLock() { 356 return this.lock; 357 } 358}