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}