• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nats-io / nats.java / #2109

15 Aug 2025 04:51PM UTC coverage: 95.404% (-0.05%) from 95.457%
#2109

push

github

web-flow
Merge pull request #1395 from nats-io/header-nullability

Header nullability

3 of 3 new or added lines in 1 file covered. (100.0%)

14 existing lines in 7 files now uncovered.

11915 of 12489 relevant lines covered (95.4%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.72
/src/main/java/io/nats/client/impl/Headers.java
1
// Copyright 2020-2025 The NATS Authors
2
// Licensed under the Apache License, Version 2.0 (the "License");
3
// you may not use this file except in compliance with the License.
4
// You may obtain a copy of the License at:
5
//
6
// http://www.apache.org/licenses/LICENSE-2.0
7
//
8
// Unless required by applicable law or agreed to in writing, software
9
// distributed under the License is distributed on an "AS IS" BASIS,
10
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
// See the License for the specific language governing permissions and
12
// limitations under the License.
13

14
package io.nats.client.impl;
15

16
import io.nats.client.support.ByteArrayBuilder;
17
import org.jspecify.annotations.NonNull;
18
import org.jspecify.annotations.Nullable;
19

20
import java.nio.charset.StandardCharsets;
21
import java.util.*;
22
import java.util.function.BiConsumer;
23

24
import static io.nats.client.support.NatsConstants.*;
25

26
/**
27
 * An object that represents a map of keys to a list of values. It does not accept
28
 * null or invalid keys. It ignores null values, accepts empty string as a value
29
 * and rejects invalid values.
30
 * !!!
31
 * THIS CLASS IS NOT THREAD SAFE
32
 */
33
public class Headers {
34

35
        private static final String KEY_CANNOT_BE_EMPTY_OR_NULL = "Header key cannot be null.";
36
        private static final String KEY_INVALID_CHARACTER = "Header key has invalid character: 0x";
37
        private static final String VALUE_INVALID_CHARACTERS = "Header value has invalid character: 0x";
38

39
        private final Map<String, List<String>> valuesMap;
40
        private final Map<String, Integer> lengthMap;
41
        private final boolean readOnly;
42
        private byte[] serialized;
43
        private int dataLength;
44

45
        /**
46
         * Create a new Headers object
47
         */
48
        public Headers() {
49
                this(null, false, null);
1✔
50
        }
1✔
51

52
        /**
53
         * Create a new Headers object by copying all header entries
54
         * @param headers the headers to copy
55
         */
56
        public Headers(@Nullable Headers headers) {
57
                this(headers, false, null);
1✔
58
        }
1✔
59

60
        /**
61
         * Create a new Headers object by copying all header entries
62
         * @param headers the headers to copy
63
         * @param readOnly flag to indicate that whether the new Headers should be marked as read-only
64
         */
65
        public Headers(@Nullable Headers headers, boolean readOnly) {
66
                this(headers, readOnly, null);
1✔
67
        }
1✔
68

69
        /**
70
         * Create a new Headers object by copying all header entries, except those indicated by keysNotToCopy
71
         * @param headers the headers to copy
72
         * @param readOnly flag to indicate that whether the new Headers should be marked as read-only
73
         * @param keysNotToCopy an array of keys that should not be copied
74
         */
75
        public Headers(@Nullable Headers headers, boolean readOnly, String @Nullable [] keysNotToCopy) {
1✔
76
                Map<String, List<String>> tempValuesMap = new HashMap<>();
1✔
77
                Map<String, Integer> tempLengthMap = new HashMap<>();
1✔
78
                if (headers != null) {
1✔
79
                        tempValuesMap.putAll(headers.valuesMap);
1✔
80
                        tempLengthMap.putAll(headers.lengthMap);
1✔
81
                        dataLength = headers.dataLength;
1✔
82
                        if (keysNotToCopy != null) {
1✔
83
                                for (String key : keysNotToCopy) {
1✔
84
                                        if (key != null) {
1✔
85
                                                if (tempValuesMap.remove(key) != null) {
1✔
86
                                                        dataLength -= tempLengthMap.remove(key);
1✔
87
                                                }
88
                                        }
89
                                }
90
                        }
91
                }
92
                this.readOnly = readOnly;
1✔
93
                if (readOnly) {
1✔
94
                        valuesMap = Collections.unmodifiableMap(tempValuesMap);
1✔
95
                        lengthMap = Collections.unmodifiableMap(tempLengthMap);
1✔
96
                }
97
                else {
98
                        valuesMap = tempValuesMap;
1✔
99
                        lengthMap = tempLengthMap;
1✔
100
                }
101
        }
1✔
102

103
        /**
104
         * If the key is present add the values to the list of values for the key.
105
         * If the key is not present, sets the specified values for the key.
106
         * null values are ignored. If all values are null, the key is not added or updated.
107
         * @param key the key
108
         * @param values the values
109
         * @return the Headers object
110
         * @throws IllegalArgumentException if the key is null or empty or contains invalid characters
111
         *         -or- if any value contains invalid characters
112
         */
113
        public Headers add(String key, String... values) {
114
                if (readOnly) {
1✔
115
                        throw new UnsupportedOperationException();
1✔
116
                }
117
                if (values == null || values.length == 0) {
1✔
118
                        return this;
1✔
119
                }
120
                return _add(key, Arrays.asList(values));
1✔
121
        }
122

123
        /**
124
         * If the key is present add the values to the list of values for the key.
125
         * If the key is not present, sets the specified values for the key.
126
         * null values are ignored. If all values are null, the key is not added or updated.
127
         * @param key the entry key
128
         * @param values a list of values to the entry
129
         * @return the Header object
130
         * @throws IllegalArgumentException if the key is null or empty or contains invalid characters
131
         *         -or- if any value contains invalid characters
132
         */
133
        public Headers add(String key, Collection<String> values) {
134
                if (readOnly) {
1✔
135
                        throw new UnsupportedOperationException();
1✔
136
                }
137
                if (values == null || values.isEmpty()) {
1✔
138
                        return this;
1✔
139
                }
140
                return _add(key, values);
1✔
141
        }
142

143
        // the add delegate
144
        private Headers _add(String key, Collection<String> values) {
145
                if (values != null) {
1✔
146
                        Checker checked = new Checker(key, values);
1✔
147
                        if (checked.hasValues()) {
1✔
148
                                // get values by key or compute empty if absent
149
                                // update the data length with the additional len
150
                                // update the lengthMap for the key to the old length plus the new length
151
                                List<String> currentSet = valuesMap.computeIfAbsent(key, k -> new ArrayList<>());
1✔
152
                                currentSet.addAll(checked.list);
1✔
153
                                dataLength += checked.len;
1✔
154
                                int oldLen = lengthMap.getOrDefault(key, 0);
1✔
155
                                lengthMap.put(key, oldLen + checked.len);
1✔
156
                                serialized = null; // since the data changed, clear this so it's rebuilt
1✔
157
                        }
158
                }
159
                return this;
1✔
160
        }
161

162
        /**
163
         * Associates the specified values with the key. If the key was already present
164
         * any existing values are removed and replaced with the new list.
165
         * null values are ignored. If all values are null, the put is ignored
166
         * @param key the key
167
         * @param values the values
168
         * @return the Headers object
169
         * @throws IllegalArgumentException if the key is null or empty or contains invalid characters
170
         *         -or- if any value contains invalid characters
171
         */
172
        public Headers put(String key, String... values) {
173
                if (readOnly) {
1✔
174
                        throw new UnsupportedOperationException();
1✔
175
                }
176
                if (values == null || values.length == 0) {
1✔
177
                        return this;
1✔
178
                }
179
                return _put(key, Arrays.asList(values));
1✔
180
        }
181

182
        /**
183
         * Associates the specified values with the key. If the key was already present
184
         * any existing values are removed and replaced with the new list.
185
         * null values are ignored. If all values are null, the put is ignored
186
         * @param key the key
187
         * @param values the values
188
         * @return the Headers object
189
         * @throws IllegalArgumentException if the key is null or empty or contains invalid characters
190
         *         -or- if any value contains invalid characters
191
         */
192
        public Headers put(String key, Collection<String> values) {
193
                if (readOnly) {
1✔
194
                        throw new UnsupportedOperationException();
1✔
195
                }
196
                if (values == null || values.isEmpty()) {
1✔
197
                        return this;
1✔
198
                }
199
                return _put(key, values);
1✔
200
        }
201

202
        /**
203
         * Associates all specified values with their key. If the key was already present
204
         * any existing values are removed and replaced with the new list.
205
         * null values are ignored. If all values are null, the put is ignored
206
         * @param map the map
207
         * @return the Headers object
208
         */
209
        public Headers put(Map<String, List<String>> map) {
210
                if (readOnly) {
1✔
211
                        throw new UnsupportedOperationException();
×
212
                }
213
                if (map == null || map.isEmpty()) {
1✔
214
                        return this;
×
215
                }
216
                for (Map.Entry<String, List<String>> entry : map.entrySet()) {
1✔
217
                        _put(entry.getKey(), entry.getValue());
1✔
218
                }
1✔
219
                return this;
1✔
220
        }
221

222
        // the put delegate
223
        private Headers _put(String key, Collection<String> values) {
224
                if (key == null || key.isEmpty()) {
1✔
225
                        throw new IllegalArgumentException("Key cannot be null or empty.");
1✔
226
                }
227
                if (values != null) {
1✔
228
                        Checker checked = new Checker(key, values);
1✔
229
                        if (checked.hasValues()) {
1✔
230
                                // update the data length removing the old length adding the new length
231
                                // put for the key
232
                                dataLength = dataLength - lengthMap.getOrDefault(key, 0) + checked.len;
1✔
233
                                valuesMap.put(key, checked.list);
1✔
234
                                lengthMap.put(key, checked.len);
1✔
235
                                serialized = null; // since the data changed, clear this so it's rebuilt
1✔
236
                        }
237
                }
238
                return this;
1✔
239
        }
240

241
        /**
242
         * Removes each key and its values if the key was present
243
         * @param keys the key or keys to remove
244
         */
245
        public void remove(String... keys) {
246
                if (readOnly) {
1✔
247
                        throw new UnsupportedOperationException();
1✔
248
                }
249
                for (String key : keys) {
1✔
250
                        _remove(key);
1✔
251
                }
252
                serialized = null; // since the data changed, clear this so it's rebuilt
1✔
253
        }
1✔
254

255
        /**
256
         * Removes each key and its values if the key was present
257
         * @param keys the key or keys to remove
258
         */
259
        public void remove(Collection<String> keys) {
260
                if (readOnly) {
1✔
261
                        throw new UnsupportedOperationException();
1✔
262
                }
263
                for (String key : keys) {
1✔
264
                        _remove(key);
1✔
265
                }
1✔
266
                serialized = null; // since the data changed, clear this so it's rebuilt
1✔
267
        }
1✔
268

269
        // the remove delegate
270
        private void _remove(String key) {
271
                // if the values had a key, then the data length had a length
272
                if (valuesMap.remove(key) != null) {
1✔
273
                        dataLength -= lengthMap.remove(key);
1✔
274
                }
275
        }
1✔
276

277
        /**
278
         * Returns the number of keys (case-sensitive) in the header.
279
         * @return the number of header entries
280
         */
281
        public int size() {
282
                return valuesMap.size();
1✔
283
        }
284

285
        /**
286
         * Returns ture if map contains no keys.
287
         * @return true if there are no headers
288
         */
289
        public boolean isEmpty() {
290
                return valuesMap.isEmpty();
1✔
291
        }
292

293
        /**
294
         * Removes all the keys The object map will be empty after this call returns.
295
         */
296
        public void clear() {
297
                if (readOnly) {
1✔
298
                        throw new UnsupportedOperationException();
1✔
299
                }
300
                valuesMap.clear();
1✔
301
                lengthMap.clear();
1✔
302
                dataLength = 0;
1✔
303
                serialized = null;
1✔
304
        }
1✔
305

306
        /**
307
         * Returns true if key (case-sensitive) is present (has values)
308
         * @param key key whose presence is to be tested
309
         * @return true if the key (case-sensitive) is present (has values)
310
         */
311
        public boolean containsKey(String key) {
312
                return valuesMap.containsKey(key);
1✔
313
        }
314

315
        /**
316
         * Returns true if key (case-insensitive) is present (has values)
317
         * @param key exact key whose presence is to be tested
318
         * @return true if the key (case-insensitive) is present (has values)
319
         */
320
        public boolean containsKeyIgnoreCase(String key) {
321
                for (String k : valuesMap.keySet()) {
1✔
322
                        if (k.equalsIgnoreCase(key)) {
1✔
323
                                return true;
1✔
324
                        }
325
                }
1✔
326
                return false;
1✔
327
        }
328

329
        /**
330
         * Returns a {@link Set} view of the keys (case-sensitive) contained in the object.
331
         * @return a read-only set the keys contained in this map
332
         */
333
        public Set<String> keySet() {
334
                return Collections.unmodifiableSet(valuesMap.keySet());
1✔
335
        }
336

337
        /**
338
         * Returns a {@link Set} view of the keys (case-insensitive) contained in the object.
339
         * @return a read-only set of keys (in lowercase) contained in this map
340
         */
341
        public Set<String> keySetIgnoreCase() {
342
                HashSet<String> set = new HashSet<>();
1✔
343
                for (String k : valuesMap.keySet()) {
1✔
344
                        set.add(k.toLowerCase());
1✔
345
                }
1✔
346
                return Collections.unmodifiableSet(set);
1✔
347
        }
348

349
        /**
350
         * Returns a {@link List} view of the values for the specific (case-sensitive) key.
351
         * Will be {@code null} if the key is not found.
352
         * @param key the key whose associated value is to be returned
353
         * @return a read-only list of the values for the case-sensitive key.
354
         */
355
        @Nullable
356
        public List<String> get(String key) {
357
                List<String> values = valuesMap.get(key);
1✔
358
                return values == null ? null : Collections.unmodifiableList(values);
1✔
359
        }
360

361
        /**
362
         * Returns the first value for the specific (case-sensitive) key.
363
         * Will be {@code null} if the key is not found.
364
         * @param key the key whose associated value is to be returned
365
         * @return the first value for the case-sensitive key.
366
         */
367
        @Nullable
368
        public String getFirst(String key) {
369
                List<String> values = valuesMap.get(key);
1✔
370
                return values == null ? null : values.get(0);
1✔
371
        }
372

373
        /**
374
         * Returns the last value for the specific (case-sensitive) key.
375
         * Will be {@code null} if the key is not found.
376
         * @param key the key whose associated value is to be returned
377
         * @return the last value for the case-sensitive key.
378
         */
379
        @Nullable
380
        public String getLast(String key) {
381
                List<String> values = valuesMap.get(key);
1✔
382
                return values == null ? null : values.get(values.size() - 1);
1✔
383
        }
384

385
        /**
386
         * Returns a {@link List} view of the values for the specific (case-insensitive) key.
387
         * Will be {@code null} if the key is not found.
388
         * @param key the key whose associated value is to be returned
389
         * @return a read-only list of the values for the case-insensitive key.
390
         */
391
        @Nullable
392
        public List<String> getIgnoreCase(String key) {
393
                List<String> values = new ArrayList<>();
1✔
394
                for (Map.Entry<String, List<String>> entry : valuesMap.entrySet()) {
1✔
395
                        if (entry.getKey().equalsIgnoreCase(key)) {
1✔
396
                                values.addAll(entry.getValue());
1✔
397
                        }
398
                }
1✔
399
                return values.isEmpty() ? null : Collections.unmodifiableList(values);
1✔
400
        }
401

402
        /**
403
         * Performs the given action for each header entry (case-sensitive keys) until all entries
404
         * have been processed or the action throws an exception.
405
         * Any attempt to modify the values will throw an exception.
406
         * @param action The action to be performed for each entry
407
         * @throws NullPointerException if the specified action is null
408
         * @throws ConcurrentModificationException if an entry is found to be
409
         * removed during iteration
410
         */
411
        public void forEach(BiConsumer<String, List<String>> action) {
412
                for (Map.Entry<String, List<String>> entry : valuesMap.entrySet()) {
1✔
413
                        action.accept(entry.getKey(), Collections.unmodifiableList(entry.getValue()));
1✔
414
                }
1✔
415
        }
1✔
416

417
        /**
418
         * Returns a {@link Set} read only view of the mappings contained in the header (case-sensitive keys).
419
         * The set is not modifiable and any attempt to modify will throw an exception.
420
         * @return a set view of the mappings contained in this map or Collections.emptySet() if there are no entries
421
         */
422
        @NonNull
423
        public Set<Map.Entry<String, List<String>>> entrySet() {
424
                return valuesMap.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(valuesMap.entrySet());
1✔
425
        }
426

427
        /**
428
         * Returns if the headers are dirty, which means the serialization
429
         * has not been done so also don't know the byte length
430
         * @return true if dirty
431
         */
432
        public boolean isDirty() {
433
                return serialized == null;
1✔
434
        }
435

436
        /**
437
         * Returns the number of bytes that will be in the serialized version.
438
         * @return the number of bytes
439
         */
440
        public int serializedLength() {
441
                return dataLength + NON_DATA_BYTES;
1✔
442
        }
443

444
        private static final int HVCRLF_BYTES = HEADER_VERSION_BYTES_PLUS_CRLF.length;
1✔
445
        private static final int NON_DATA_BYTES = HVCRLF_BYTES + 2;
1✔
446

447
        /**
448
         * Returns the serialized bytes.
449
         * @return the bytes
450
         */
451
        public byte @NonNull [] getSerialized() {
452
                if (serialized == null) {
1✔
453
                        serialized = new byte[serializedLength()];
1✔
454
                        serializeToArray(0, serialized);
1✔
455
                }
456
                return serialized;
1✔
457
        }
458

459
        /**
460
         * @deprecated
461
         * Used for unit testing.
462
     * Appends the serialized bytes to the builder. 
463
         * @param bab the ByteArrayBuilder to append
464
         * @return the builder
465
         */
466
        @Deprecated
467
        public ByteArrayBuilder appendSerialized(ByteArrayBuilder bab) {
468
                bab.append(HEADER_VERSION_BYTES_PLUS_CRLF);
×
469
                for (Map.Entry<String, List<String>> entry : valuesMap.entrySet()) {
×
470
                        for (String value : entry.getValue()) {
×
471
                                bab.append(entry.getKey());
×
472
                                bab.append(COLON_BYTES);
×
473
                                bab.append(value);
×
474
                                bab.append(CRLF_BYTES);
×
475
                        }
×
476
                }
×
UNCOV
477
                bab.append(CRLF_BYTES);
×
UNCOV
478
                return bab;
×
479
        }
480

481
        /**
482
         * Write the header to the byte array. Assumes that the caller has
483
         * already validated that the destination array is large enough by using {@link #serializedLength()}.
484
         * <p>deprecated {@link String#getBytes(int, int, byte[], int)} is used, because it still exists in JDK 25
485
         * and is 10–30 times faster than {@code getBytes(ISO_8859_1/US_ASCII)}/
486
         * @param destPosition the position index in destination byte array to start
487
         * @param dest the byte array to write to
488
         * @return the length of the header
489
         */
490
        public int serializeToArray(int destPosition, byte[] dest) {
491
                System.arraycopy(HEADER_VERSION_BYTES_PLUS_CRLF, 0, dest, destPosition, HVCRLF_BYTES);
1✔
492
                destPosition += HVCRLF_BYTES;
1✔
493

494
                for (Map.Entry<String, List<String>> entry : valuesMap.entrySet()) {
1✔
495
                        String key = entry.getKey();
1✔
496
                        for (String value : entry.getValue()) {
1✔
497
                //noinspection deprecation
498
                key.getBytes(0, key.length(), dest, destPosition);// key has only US_ASCII
1✔
499
                                destPosition += key.length();
1✔
500

501
                                dest[destPosition++] = COLON;
1✔
502

503
                                //noinspection deprecation
504
                                value.getBytes(0, value.length(), dest, destPosition);
1✔
505
                                destPosition += value.length();
1✔
506

507
                                dest[destPosition++] = CR;
1✔
508
                                dest[destPosition++] = LF;
1✔
509
                        }
1✔
510
                }
1✔
511
                dest[destPosition++] = CR;
1✔
512
                dest[destPosition] = LF;
1✔
513

514
                return serializedLength();
1✔
515
        }
516

517
        /**
518
         * Check the key to ensure it matches the specification for keys.
519
         * @throws IllegalArgumentException if the key is null, empty or contains
520
         *         an invalid character
521
         */
522
        static void checkKey(String key) {
523
                // key cannot be null or empty and contain only printable characters except colon
524
                if (key == null || key.isEmpty()) {
1✔
525
                        throw new IllegalArgumentException(KEY_CANNOT_BE_EMPTY_OR_NULL);
1✔
526
                }
527

528
                int len = key.length();
1✔
529
                for (int idx = 0; idx < len; idx++) {
1✔
530
                        char c = key.charAt(idx);
1✔
531
                        if (c < 33 || c > 126 || c == ':') {
1✔
532
                                throw new IllegalArgumentException(KEY_INVALID_CHARACTER + Integer.toHexString(c));
1✔
533
                        }
534
                }
535
        }
1✔
536

537
        /**
538
         * Check a non-null value if it matches the specification for values.
539
         * @throws IllegalArgumentException if the value contains an invalid character
540
         */
541
        static void checkValue(String val) {
542
                // Like rfc822 section 3.1.2 (quoted in ADR 4)
543
                // The field-body may be composed of any US-ASCII characters, except CR or LF.
544
                for (int i = 0, len = val.length(); i < len; i++) {
1✔
545
                        int c = val.charAt(i);
1✔
546
                        if (c > 127 || c == 10 || c == 13) {
1✔
547
                                throw new IllegalArgumentException(VALUE_INVALID_CHARACTERS + Integer.toHexString(c));
1✔
548
                        }
549
                }
550
        }
1✔
551

552
        private static final class Checker {
553
                List<String> list = new ArrayList<>();
1✔
554
                int len = 0;
1✔
555

556
                Checker(String key, Collection<String> values) {
1✔
557
                        checkKey(key);
1✔
558
                        if (!values.isEmpty()) {
1✔
559
                                for (String val : values) {
1✔
560
                                        if (val != null) {
1✔
561
                                                if (val.isEmpty()) {
1✔
562
                                                        list.add(val);
1✔
563
                                                        len += key.length() + 3; // for colon, cr, lf
1✔
564
                                                }
565
                                                else {
566
                                                        checkValue(val);
1✔
567
                                                        list.add(val);
1✔
568
                                                        len += key.length() + val.length() + 3; // for colon, cr, lf
1✔
569
                                                }
570
                                        }
571
                                }
1✔
572
                        }
573
                }
1✔
574

575
                boolean hasValues() {
576
                        return !list.isEmpty();
1✔
577
                }
578
        }
579

580
        /**
581
         * Whether the entire Headers is read only
582
         * @return the read only state
583
         */
584
        public boolean isReadOnly() {
585
                return readOnly;
1✔
586
        }
587

588
        @Override
589
        public boolean equals(Object o) {
590
                if (this == o) return true;
1✔
591
                if (!(o instanceof Headers)) return false;
1✔
592
                Headers headers = (Headers) o;
1✔
593
                return Objects.equals(valuesMap, headers.valuesMap);
1✔
594
        }
595

596
        @Override
597
        public int hashCode() {
598
                return Objects.hashCode(valuesMap);
1✔
599
        }
600

601
        @Override
602
        public String toString() {
603
                byte[] b = getSerialized();
1✔
604
                int len = b.length;
1✔
605
                if (len <= HVCRLF_BYTES + 2){
1✔
606
                        return "";// empty map
1✔
607
                }
608
                for (int i = 0; i < len; i++) {
1✔
609
                        switch (b[i]) {
1✔
610
                                case CR: b[i] = ';'; break;
1✔
611
                                case LF: b[i] = ' '; break;
1✔
612
                        }
613
                }
614
                return new String(b, HVCRLF_BYTES, len - HVCRLF_BYTES - 3, StandardCharsets.ISO_8859_1);// b has only US_ASCII, ISO_8859_1 is 3x faster
1✔
615
        }
616
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc