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}