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