001/*
002 * The contents of this file are subject to the license and copyright
003 * detailed in the LICENSE and NOTICE files at the root of the source
004 * tree.
005 */
006package org.fcrepo.client;
007
008import java.net.URI;
009import java.util.ArrayList;
010import java.util.HashMap;
011import java.util.List;
012import java.util.Map;
013import java.util.StringTokenizer;
014
015/**
016 * A class representing the value of an HTTP Link header
017 *
018 * @author Aaron Coburn
019 * @author bbpennel
020 */
021public class FcrepoLink {
022
023    private static final String PARAM_DELIM = ";";
024
025    private static final String META_REL = "rel";
026
027    private static final String META_TYPE = "type";
028
029    private URI uri;
030
031    private Map<String, String> params;
032
033    /**
034     * Create a representation of a Link header.
035     *
036     * @param link the value for a Link header
037     */
038    public FcrepoLink(final String link) {
039        if (link == null) {
040            throw new IllegalArgumentException("Link header did not contain a URI");
041        }
042        this.params = new HashMap<>();
043        parse(link);
044    }
045
046    /**
047     * Construct a representation of a Link header from the given uri and parameters.
048     *
049     * @param uri URI portion of the link header
050     * @param params link parameters
051     */
052    private FcrepoLink(final URI uri, final Map<String, String> params) {
053        this.uri = uri;
054        this.params = params;
055    }
056
057    /**
058     * Retrieve the URI of the link
059     *
060     * @return the URI portion of a Link header
061     */
062    public URI getUri() {
063        return uri;
064    }
065
066    /**
067     * Retrieve the REL portion of the link
068     *
069     * @return the "rel" portion of a Link header
070     */
071    public String getRel() {
072        return getParam(META_REL);
073    }
074
075    /**
076     * Retrieve the type portion of the link
077     *
078     * @return the "type" parameter of the header
079     */
080    public String getType() {
081        return getParam(META_TYPE);
082    }
083
084    /**
085     * Retrieve a parameter from the link header
086     *
087     * @param name name of the parameter in the link header
088     * @return the value of the parameter or null if not present.
089     */
090    public String getParam(final String name) {
091        return params.get(name);
092    }
093
094    /**
095     * Retrieve a map of parameters from the link header
096     *
097     * @return map of parameters
098     */
099    public Map<String, String> getParams() {
100        return params;
101    }
102
103    /**
104     * Parse the value of a link header
105     */
106    private void parse(final String link) {
107        final int paramIndex = link.indexOf(PARAM_DELIM);
108        if (paramIndex == -1) {
109            uri = getLinkPart(link);
110        } else {
111            uri = getLinkPart(link.substring(0, paramIndex));
112            // Parse the remainder of the header after the URI as parameters
113            parseParams(link.substring(paramIndex + 1));
114        }
115    }
116
117    private void parseParams(final String paramString) {
118        final StringTokenizer st = new StringTokenizer(paramString, ";\",", true);
119        while (st.hasMoreTokens()) {
120            // Read one parameter, until an unquoted ; is encountered or no more tokens
121            boolean inQuotes = false;
122            final StringBuilder paramBuilder = new StringBuilder();
123            while (st.hasMoreTokens()) {
124                final String token = st.nextToken();
125                if (token.equals("\"")) {
126                    inQuotes = !inQuotes;
127                } else if (!inQuotes && token.equals(";")) {
128                    break;
129                } else if (!inQuotes && token.equals(",")) {
130                    throw new IllegalArgumentException("Cannot parse link, contains unterminated quotes");
131                } else {
132                    paramBuilder.append(token);
133                }
134            }
135
136            if (inQuotes) {
137                throw new IllegalArgumentException("Cannot parse link, contains unterminated quotes");
138            }
139
140            final String param = paramBuilder.toString();
141            final String[] components = param.split("=", 2);
142            if (components.length == 2) {
143                final String name = components[0].trim();
144                final String value = components[1].trim();
145                params.put(name, value);
146            } else {
147                throw new IllegalArgumentException(
148                        "Cannot parse link, improperly structured parameter encountered: " + param);
149            }
150        }
151    }
152
153    private static String stripQuotes(final String value) {
154        if (value.startsWith("\"") && value.endsWith("\"")) {
155            return value.substring(1, value.length() - 1);
156        }
157        return value;
158    }
159
160    @Override
161    public String toString() {
162        final StringBuilder result = new StringBuilder();
163        result.append('<').append(uri.toString()).append('>');
164
165        params.forEach((name, value) -> {
166            result.append("; ").append(name).append("=\"").append(value).append('"');
167        });
168        return result.toString();
169    }
170
171    /**
172     * Extract the URI part of the link header
173     */
174    private static URI getLinkPart(final String uriPart) {
175        final String linkPart = uriPart.trim();
176        if (!linkPart.startsWith("<") || !linkPart.endsWith(">")) {
177            throw new IllegalArgumentException("Link header did not contain a URI");
178        } else {
179            return URI.create(linkPart.substring(1, linkPart.length() - 1));
180        }
181    }
182
183    /**
184     * Create a new builder instance initialized from an existing URI represented as a string.
185     *
186     * @param uri URI which will be used to initialize the builder
187     * @return a new link builder.
188     * @throws IllegalArgumentException if uri is {@code null}.
189     */
190    public static Builder fromUri(final String uri) {
191        if (uri == null) {
192            throw new IllegalArgumentException("URI is required");
193        }
194        return new Builder().uri(uri);
195    }
196
197    /**
198     * Create a new builder instance initialized from an existing URI.
199     *
200     * @param uri URI which will be used to initialize the builder
201     * @return a new link builder.
202     * @throws IllegalArgumentException if uri is {@code null}.
203     */
204    public static Builder fromUri(final URI uri) {
205        if (uri == null) {
206            throw new IllegalArgumentException("URI is required");
207        }
208        return new Builder().uri(uri);
209    }
210
211    /**
212     * Simple parser to convert a link header containing a single link into an FcrepoLink object.
213     *
214     * @param link link header value.
215     * @return FcrepoLink representing the link
216     */
217    public static FcrepoLink valueOf(final String link) {
218        return new FcrepoLink(link);
219    }
220
221    /**
222     * Parser which converts a link header containing one or more links into a list of FcrepoLink objects.
223     *
224     * @param headerValue link header value.
225     * @return List containing an FcrepoLink for each link in the header value.
226     */
227    public static List<FcrepoLink> fromHeader(final String headerValue) {
228        final List<FcrepoLink> links = new ArrayList<>();
229
230        // States which indicate that a "," is not currently a link delimiter
231        boolean inQuotes = false;
232        boolean inUri = false;
233
234        // Split the header into separate links and create FcrepoLink objects for each
235        // Allows for reserved characters to appear within quoted values or the URI
236        StringBuilder currentLink = new StringBuilder();
237        final StringTokenizer st = new StringTokenizer(headerValue, ",\"<>", true);
238        while (st.hasMoreTokens()) {
239            final String token = st.nextToken();
240            if (token.equals(",")) {
241                // Link delimiter, add current link to result and start next link
242                if (!inQuotes && !inUri) {
243                    links.add(new FcrepoLink(currentLink.toString().trim()));
244                    currentLink = new StringBuilder();
245                    continue;
246                }
247            } else if (token.equals("\"") && !inUri) {
248                inQuotes = !inQuotes;
249            } else if (token.equals("<") && !inQuotes) {
250                inUri = true;
251            } else if (token.equals(">") && !inQuotes) {
252                inUri = false;
253            }
254
255            // Accumulate tokens composing this link
256            currentLink.append(token);
257        }
258
259        if (inQuotes) {
260            throw new IllegalArgumentException("Cannot parse link header, contains unterminated quotes: "
261                    + headerValue);
262        }
263        if (inUri) {
264            throw new IllegalArgumentException("Cannot parse link header, contains unterminated URI: "
265                    + headerValue);
266        }
267
268        links.add(new FcrepoLink(currentLink.toString().trim()));
269        return links;
270    }
271
272    /**
273     * Builder class for link headers represented as FcrepoLinks
274     *
275     * @author bbpennel
276     */
277    public static class Builder {
278
279        private URI uri;
280
281        private Map<String, String> params;
282
283        /**
284         * Construct a builder
285         */
286        public Builder() {
287            this.params = new HashMap<>();
288        }
289
290        /**
291         * Set the URI for this link
292         *
293         * @param uri URI for link
294         * @return this builder
295         */
296        public Builder uri(final URI uri) {
297            this.uri = uri;
298            return this;
299        }
300
301        /**
302         * Set the URI for this link
303         *
304         * @param uri URI for link
305         * @return this builder
306         */
307        public Builder uri(final String uri) {
308            this.uri = URI.create(uri);
309            return this;
310        }
311
312        /**
313         * Set a rel parameter for this link
314         *
315         * @param rel rel param value
316         * @return this builder
317         */
318        public Builder rel(final String rel) {
319            return param(META_REL, rel);
320        }
321
322        /**
323         * Set a type parameter for this link
324         *
325         * @param type type param value
326         * @return this builder
327         */
328        public Builder type(final String type) {
329            return param(META_TYPE, type);
330        }
331
332        /**
333         * Set an arbitrary parameter for this link
334         *
335         * @param name name of the parameter
336         * @param value value of the parameter
337         * @return this builder
338         */
339        public Builder param(final String name, final String value) {
340            params.put(name, stripQuotes(value));
341            return this;
342        }
343
344        /**
345         * Finish building this link.
346         *
347         * @return newly built link.
348         */
349        public FcrepoLink build() {
350            return new FcrepoLink(uri, params);
351        }
352    }
353}