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}