View Javadoc
1   /*
2    * SPDX-FileCopyrightText: Copyright (c) 2012-2026 Yegor Bugayenko
3    * SPDX-License-Identifier: MIT
4    */
5   package com.jcabi.urn;
6   
7   import com.jcabi.aspects.Immutable;
8   import java.io.Serializable;
9   import java.io.UnsupportedEncodingException;
10  import java.net.URI;
11  import java.net.URISyntaxException;
12  import java.net.URLDecoder;
13  import java.util.Map;
14  import java.util.Objects;
15  import java.util.TreeMap;
16  import org.apache.commons.lang3.StringUtils;
17  
18  /**
19   * Uniform Resource Name (URN) as in
20   * <a href="http://tools.ietf.org/html/rfc2141">RFC 2141</a>.
21   *
22   * <p>Usage is similar to {@link java.net.URI} or {@link java.net.URL}:
23   *
24   * <pre> URN urn = new URN("urn:foo:A123,456");
25   * assert urn.nid().equals("foo");
26   * assert urn.nss().equals("A123,456");</pre>
27   *
28   * <p><b>NOTICE:</b> the implementation is not fully compliant with RFC 2141.
29   * It will become compliant in one of our future versions. Once it becomes
30   * fully compliant this notice will be removed.
31   *
32   * @see <a href="http://tools.ietf.org/html/rfc2141">RFC 2141</a>
33   * @since 0.6
34   * @checkstyle AbbreviationAsWordInNameCheck (500 lines)
35   */
36  @Immutable
37  @SuppressWarnings({
38      "PMD.TooManyMethods", "PMD.GodClass",
39      "PMD.OnlyOneConstructorShouldDoInitialization",
40      "PMD.ConstructorOnlyInitializesOrCallOtherConstructors"
41  })
42  public final class URN implements Comparable<URN>, Serializable {
43  
44      /**
45       * Serialization marker.
46       */
47      private static final long serialVersionUID = 0xBF46AFCD9612A6DFL;
48  
49      /**
50       * Encoding to use.
51       */
52      private static final String ENCODING = "UTF-8";
53  
54      /**
55       * NID of an empty URN.
56       */
57      private static final String EMPTY = "void";
58  
59      /**
60       * The leading sequence.
61       */
62      private static final String PREFIX = "urn";
63  
64      /**
65       * The separator.
66       */
67      private static final String SEP = ":";
68  
69      /**
70       * Validating regular expr.
71       */
72      private static final String REGEX =
73          // @checkstyle LineLength (1 line)
74          "^(?i)^urn(?-i):[a-z]{1,31}(:([\\-a-zA-Z0-9/]|%[0-9a-fA-F]{2})*)+(\\?\\w+(=([\\-a-zA-Z0-9/]|%[0-9a-fA-F]{2})*)?(&\\w+(=([\\-a-zA-Z0-9/]|%[0-9a-fA-F]{2})*)?)*)?\\*?$";
75  
76      /**
77       * The URI.
78       */
79      private final String uri;
80  
81      /**
82       * Public ctor (for JAXB mostly) that creates an "empty" URN.
83       */
84      public URN() {
85          this(URN.EMPTY, "");
86      }
87  
88      /**
89       * Public ctor.
90       * @param text The text of the URN
91       * @throws URISyntaxException If syntax is not correct
92       * @checkstyle ConstructorsCodeFreeCheck (10 lines)
93       */
94      public URN(final String text) throws URISyntaxException {
95          if (text == null) {
96              throw new IllegalArgumentException("text can't be NULL");
97          }
98          if (!text.matches(URN.REGEX)) {
99              throw new URISyntaxException(text, "Invalid format of URN");
100         }
101         this.uri = text;
102         this.validate();
103     }
104 
105     /**
106      * Public ctor.
107      * @param nid The namespace ID
108      * @param nss The namespace specific string
109      * @checkstyle ConstructorsCodeFreeCheck (20 lines)
110      * @checkstyle ConstructorsOrderCheck (20 lines)
111      */
112     public URN(final String nid, final String nss) {
113         if (nid == null) {
114             throw new IllegalArgumentException("NID can't be NULL");
115         }
116         if (nss == null) {
117             throw new IllegalArgumentException("NSS can't be NULL");
118         }
119         this.uri = String.format(
120             "%s%s%s%2$s%s",
121             URN.PREFIX,
122             URN.SEP,
123             nid,
124             URN.encode(nss)
125         );
126         try {
127             this.validate();
128         } catch (final URISyntaxException ex) {
129             throw new IllegalArgumentException(ex);
130         }
131     }
132 
133     /**
134      * Creates an instance of URN and throws a runtime exception if
135      * its syntax is not valid.
136      * @param text The text of the URN
137      * @return The URN created
138      */
139     @SuppressWarnings("PMD.ProhibitPublicStaticMethods")
140     public static URN create(final String text) {
141         if (text == null) {
142             throw new IllegalArgumentException("URN can't be NULL");
143         }
144         try {
145             // @checkstyle QualifyInnerClassCheck (1 line)
146             return new URN(text);
147         } catch (final URISyntaxException ex) {
148             throw new IllegalArgumentException(ex);
149         }
150     }
151 
152     @Override
153     public String toString() {
154         return this.uri;
155     }
156 
157     @Override
158     public boolean equals(final Object obj) {
159         final boolean result;
160         if (this == obj) {
161             result = true;
162         } else if (obj instanceof URN) {
163             result = Objects.equals(this.uri, ((URN) obj).uri);
164         } else {
165             result = false;
166         }
167         return result;
168     }
169 
170     @Override
171     public int hashCode() {
172         return Objects.hashCode(this.uri);
173     }
174 
175     @Override
176     public int compareTo(final URN urn) {
177         return this.uri.compareTo(urn.uri);
178     }
179 
180     /**
181      * Is it a valid URN?
182      * @param text The text to validate
183      * @return Yes of no
184      */
185     @SuppressWarnings("PMD.ProhibitPublicStaticMethods")
186     public static boolean isValid(final String text) {
187         boolean valid = true;
188         try {
189             // @checkstyle QualifyInnerClassCheck (1 line)
190             new URN(text);
191         } catch (final URISyntaxException ex) {
192             valid = false;
193         }
194         return valid;
195     }
196 
197     /**
198      * Does it match the pattern?
199      * @param pattern The pattern to match
200      * @return Yes of no
201      */
202     public boolean matches(final String pattern) {
203         if (pattern == null) {
204             throw new IllegalArgumentException("pattern can't be NULL");
205         }
206         boolean matches = false;
207         if (this.toString().equals(pattern)) {
208             matches = true;
209         } else if (pattern.endsWith("*")) {
210             matches = this.uri.startsWith(
211                 pattern.substring(0, pattern.length() - 1)
212             );
213         }
214         return matches;
215     }
216 
217     /**
218      * Is it empty?
219      * @return Yes of no
220      */
221     public boolean isEmpty() {
222         return URN.EMPTY.equals(this.nid());
223     }
224 
225     /**
226      * Convert it to URI.
227      * @return The URI
228      */
229     public URI toURI() {
230         return URI.create(this.uri);
231     }
232 
233     /**
234      * Get namespace ID.
235      * @return Namespace ID
236      */
237     public String nid() {
238         return this.segment(1);
239     }
240 
241     /**
242      * Get namespace specific string.
243      * @return Namespace specific string
244      */
245     public String nss() {
246         try {
247             return URLDecoder.decode(this.segment(2), URN.ENCODING);
248         } catch (final UnsupportedEncodingException ex) {
249             throw new IllegalStateException(ex);
250         }
251     }
252 
253     /**
254      * Get all params.
255      * @return The params
256      */
257     public Map<String, String> params() {
258         return URN.demap(this.toString());
259     }
260 
261     /**
262      * Get query param by name.
263      * @param name Name of parameter
264      * @return The value of it
265      */
266     public String param(final String name) {
267         if (name == null) {
268             throw new IllegalArgumentException("param name can't be NULL");
269         }
270         final Map<String, String> params = this.params();
271         if (!params.containsKey(name)) {
272             throw new IllegalArgumentException(
273                 String.format(
274                     "Param '%s' not found in '%s', among %s",
275                     name,
276                     this,
277                     params.keySet()
278                 )
279             );
280         }
281         return params.get(name);
282     }
283 
284     /**
285      * Add (overwrite) a query param and return a new URN.
286      * @param name Name of parameter
287      * @param value The value of parameter
288      * @return New URN
289      */
290     public URN param(final String name, final Object value) {
291         if (name == null) {
292             throw new IllegalArgumentException("param can't be NULL");
293         }
294         if (value == null) {
295             throw new IllegalArgumentException("param value can't be NULL");
296         }
297         final Map<String, String> params = this.params();
298         params.put(name, value.toString());
299         return URN.create(
300             String.format(
301                 "%s%s",
302                 StringUtils.split(this.toString(), '?')[0],
303                 URN.enmap(params)
304             )
305         );
306     }
307 
308     /**
309      * Get just body of URN, without params.
310      * @return Clean version of it
311      */
312     public URN pure() {
313         String urn = this.toString();
314         if (this.hasParams()) {
315             // @checkstyle MultipleStringLiterals (1 line)
316             urn = urn.substring(0, urn.indexOf('?'));
317         }
318         return URN.create(urn);
319     }
320 
321     /**
322      * Whether this URN has params?
323      * @return Has them?
324      */
325     public boolean hasParams() {
326         // @checkstyle MultipleStringLiterals (1 line)
327         return this.toString().contains("?");
328     }
329 
330     /**
331      * Get segment by position.
332      * @param pos Its position
333      * @return The segment
334      */
335     private String segment(final int pos) {
336         return StringUtils.splitPreserveAllTokens(
337             this.uri,
338             URN.SEP,
339             // @checkstyle MagicNumber (1 line)
340             3
341         )[pos];
342     }
343 
344     /**
345      * Validate URN.
346      * @throws URISyntaxException If it's not valid
347      */
348     private void validate() throws URISyntaxException {
349         if (this.isEmpty() && !this.nss().isEmpty()) {
350             throw new URISyntaxException(
351                 this.toString(),
352                 "Empty URN can't have NSS"
353             );
354         }
355         final String nid = this.nid();
356         if (!nid.matches("^[a-z]{1,31}$")) {
357             throw new IllegalArgumentException(
358                 String.format(
359                     "NID '%s' can contain up to 31 low case letters",
360                     this.nid()
361                 )
362             );
363         }
364         if (URN.PREFIX.equalsIgnoreCase(nid)) {
365             throw new IllegalArgumentException(
366                 "NID can't be 'urn' according to RFC 2141, section 2.1"
367             );
368         }
369     }
370 
371     /**
372      * Decode query part of the URN into Map.
373      * @param urn The URN to demap
374      * @return The map of values
375      */
376     private static Map<String, String> demap(final String urn) {
377         final Map<String, String> map = new TreeMap<>();
378         final String[] sectors = StringUtils.split(urn, '?');
379         if (sectors.length == 2) {
380             final String[] parts = StringUtils.split(sectors[1], '&');
381             for (final String part : parts) {
382                 final String[] pair = StringUtils.split(part, '=');
383                 final String value;
384                 if (pair.length == 2) {
385                     try {
386                         value = URLDecoder.decode(pair[1], URN.ENCODING);
387                     } catch (final UnsupportedEncodingException ex) {
388                         throw new IllegalStateException(ex);
389                     }
390                 } else {
391                     value = "";
392                 }
393                 map.put(pair[0], value);
394             }
395         }
396         return map;
397     }
398 
399     /**
400      * Encode map of params into query part of URN.
401      * @param params Map of params to convert to query suffix
402      * @return The suffix of URN, starting with "?"
403      */
404     private static String enmap(final Map<String, String> params) {
405         final StringBuilder query = new StringBuilder(100);
406         if (!params.isEmpty()) {
407             query.append('?');
408             boolean first = true;
409             for (final Map.Entry<String, String> param : params.entrySet()) {
410                 if (!first) {
411                     query.append('&');
412                 }
413                 query.append(param.getKey());
414                 if (!param.getValue().isEmpty()) {
415                     query.append('=').append(URN.encode(param.getValue()));
416                 }
417                 first = false;
418             }
419         }
420         return query.toString();
421     }
422 
423     /**
424      * Perform proper URL encoding with the text.
425      * @param text The text to encode
426      * @return The encoded text
427      */
428     private static String encode(final String text) {
429         final byte[] bytes;
430         try {
431             bytes = text.getBytes(URN.ENCODING);
432         } catch (final UnsupportedEncodingException ex) {
433             throw new IllegalStateException(ex);
434         }
435         final StringBuilder encoded = new StringBuilder(100);
436         for (final byte chr : bytes) {
437             if (URN.allowed(chr)) {
438                 encoded.append((char) chr);
439             } else {
440                 encoded.append('%').append(String.format("%X", chr));
441             }
442         }
443         return encoded.toString();
444     }
445 
446     /**
447      * This char is allowed in URN's NSS part?
448      * @param chr The character
449      * @return It is allowed?
450      */
451     private static boolean allowed(final byte chr) {
452         // @checkstyle BooleanExpressionComplexity (4 lines)
453         return chr >= 'A' && chr <= 'Z'
454             || chr >= '0' && chr <= '9'
455             || chr >= 'a' && chr <= 'z'
456             || chr == '/' || chr == '-';
457     }
458 }