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}