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