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