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