diff --git a/.gitattributes b/.gitattributes index 3a53f356e..592616cca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,20 +1,7 @@ -# Check out all text files in UNIX format. -* text eol=lf +# Check out all text files in UNIX format, with LF as end of line +# Don't change this file. If you have any ideas about it, please +# submit a separate issue about it and we'll discuss. -# Explicitly declare text files we want to always be normalized and converted -# to native line endings on checkout. -*.txt text -*.java text -*.groovy text -*.xml text -*.md text -*.pom text -*.properties text -*.tex text -*.vm text -*.xsl text -*.yml text - -# Denote all files that are truly binary and should not be modified. -*.png binary -*.jpg binary +* text=auto eol=lf +*.java ident +*.xml ident diff --git a/.rultor.yml b/.rultor.yml index 0d945c069..852e52641 100644 --- a/.rultor.yml +++ b/.rultor.yml @@ -42,7 +42,6 @@ merge: - krzyk - longtimeago - pinaf - - yegor256 deploy: script: | version=$(curl -K ../curl-appveyor.cfg --data "{accountName: 'yegor256', projectSlug: 'takes', branch: 'master'}" https://ci.appveyor.com/api/builds | jq -r '.version') diff --git a/est/takes_09_06_2015.est b/est/takes_09_06_2015.est new file mode 100644 index 000000000..57e51094b --- /dev/null +++ b/est/takes_09_06_2015.est @@ -0,0 +1,17 @@ +date: 09-06-2015 +author: Igor Khvostenkov +method: champions.pert +scope: + 1: Design of the interfaces + 2: Routing/Dispatching + 3: Persistent entities + 4: Performance testing +champions: + 1: + worst-case: 60 + best-case: 25 + most-likely: 35 + 2: + worst-case: 40 + best-case: 15 + most-likely: 25 \ No newline at end of file diff --git a/src/main/java/org/takes/facets/auth/PsBasic.java b/src/main/java/org/takes/facets/auth/PsBasic.java new file mode 100644 index 000000000..a8575ad6c --- /dev/null +++ b/src/main/java/org/takes/facets/auth/PsBasic.java @@ -0,0 +1,188 @@ +/** + * The MIT License (MIT) + * + * Copyright (c) 2015 Yegor Bugayenko + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.takes.facets.auth; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.logging.Level; +import javax.xml.bind.DatatypeConverter; +import lombok.EqualsAndHashCode; +import org.takes.Request; +import org.takes.Response; +import org.takes.facets.flash.RsFlash; +import org.takes.facets.forward.RsForward; +import org.takes.misc.Opt; +import org.takes.rq.RqHeaders; +import org.takes.rq.RqHref; +import org.takes.rs.RsWithHeader; + +/** + * Pass that checks the user according RFC-2617. + * + *

The class is immutable and thread-safe. + * + * @author Endrigo Antonini (teamed@endrigo.com.br) + * @version $Id$ + * @since 0.20 + * @checkstyle ClassDataAbstractionCouplingCheck (250 lines) + */ +@EqualsAndHashCode(of = { "entry", "realm" }) +public final class PsBasic implements Pass { + + /** + * Authorization response HTTP head. + */ + private static final String AUTH_HEAD = "Basic"; + + /** + * Entry to validate user information. + */ + private final transient PsBasic.Entry entry; + + /** + * Realm. + */ + private final transient String realm; + + /** + * Ctor. + * @param rlm Realm + * @param basic Entry + */ + public PsBasic(final String rlm, final PsBasic.Entry basic) { + this.realm = rlm; + this.entry = basic; + } + + @Override + public Opt enter(final Request request) throws IOException { + final String decoded = new String( + DatatypeConverter.parseBase64Binary( + new RqHeaders.Smart( + new RqHeaders.Base(request) + ).single("authorization").split(AUTH_HEAD)[1] + ) + ).trim(); + final String user = decoded.split(":")[0]; + final Opt identity = this.entry.enter( + user, + decoded.substring(user.length() + 1) + ); + if (!identity.has()) { + throw new RsForward( + new RsWithHeader( + new RsFlash("access denied", Level.WARNING), + String.format( + "WWW-Authenticate: Basic ream=\"%s\"", + this.realm + ) + ), + HttpURLConnection.HTTP_UNAUTHORIZED, + new RqHref.Base(request).href() + ); + } + return identity; + } + + @Override + public Response exit(final Response response, final Identity identity) + throws IOException { + return response; + } + + /** + * Entry interface that is used to check if the received information is + * valid. + * + * @author Endrigo Antonini (teamed@endrigo.com.br) + * @version $Id$ + * @since 0.20 + */ + public interface Entry { + + /** + * Check if is a valid user. + * @param user User + * @param pwd Password + * @return Identity. + */ + Opt enter(String user, String pwd); + } + + /** + * Fake implementation of {@link PsBasic.Entry}. + * + *

The class is immutable and thread-safe. + * + * @author Endrigo Antonini (teamed@endrigo.com.br) + * @version $Id$ + * @since 0.20 + * + */ + public static final class Fake implements PsBasic.Entry { + + /** + * Should we authenticate a user? + */ + private final transient boolean condition; + + /** + * Ctor. + * @param cond Condition + */ + public Fake(final boolean cond) { + this.condition = cond; + } + + @Override + public Opt enter(final String usr, final String pwd) { + final Opt user; + if (this.condition) { + user = new Opt.Single( + new Identity.Simple( + String.format("urn:basic:%s", usr) + ) + ); + } else { + user = new Opt.Empty(); + } + return user; + } + } + + /** + * Empty check. + * + * @author Endrigo Antonini (teamed@endrigo.com.br) + * @version $Id$ + * @since 0.20 + */ + public static final class Empty implements PsBasic.Entry { + + @Override + public Opt enter(final String user, final String pwd) { + return new Opt.Empty(); + } + } +} diff --git a/src/main/java/org/takes/facets/auth/social/PsTwitter.java b/src/main/java/org/takes/facets/auth/social/PsTwitter.java index 2538cc85c..5893f7a5f 100644 --- a/src/main/java/org/takes/facets/auth/social/PsTwitter.java +++ b/src/main/java/org/takes/facets/auth/social/PsTwitter.java @@ -28,6 +28,7 @@ import com.jcabi.http.response.RestResponse; import java.io.IOException; import java.net.HttpURLConnection; +import java.net.URI; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import javax.json.JsonObject; @@ -53,6 +54,12 @@ @EqualsAndHashCode(of = { "app", "key" }) public final class PsTwitter implements Pass { + /** + * URL for verifying user credentials. + */ + private static final String VERIFY_URL = + "https://api.twitter.com/1.1/account/verify_credentials.json"; + /** * App name. */ @@ -63,58 +70,111 @@ public final class PsTwitter implements Pass { */ private final transient String key; + /** + * Request for fetching app token. + */ + private final transient com.jcabi.http.Request token; + + /** + * Request for verifying user credentials. + */ + private final transient com.jcabi.http.Request user; + /** * Ctor. - * @param gapp Twitter app - * @param gkey Twitter key + * @param name Twitter app + * @param keys Twitter key */ - public PsTwitter(final String gapp, final String gkey) { - this.app = gapp; - this.key = gkey; + public PsTwitter(final String name, final String keys) { + this( + new JdkRequest( + new Href("https://api.twitter.com/oauth2/token") + .with("grant_type", "client_credentials") + .toString() + ), + new JdkRequest(PsTwitter.VERIFY_URL), name, keys + ); + } + + /** + * Ctor with proper requestor for testing purposes. + * @param tkn HTTP request for getting token + * @param creds HTTP request for verifying credentials + * @param name Facebook app + * @param keys Facebook key + * @checkstyle ParameterNumberCheck (3 lines) + */ + PsTwitter(final com.jcabi.http.Request tkn, + final com.jcabi.http.Request creds, + final String name, + final String keys) { + this.token = tkn; + this.user = creds; + this.app = name; + this.key = keys; } @Override public Opt enter(final Request request) throws IOException { - return new Opt.Single(PsTwitter.fetch(this.token())); + return new Opt.Single(this.identity(this.fetch())); } @Override - public Response exit(final Response response, - final Identity identity) { + public Response exit(final Response response, final Identity identity) { return response; } /** * Get user name from Twitter, with the token provided. - * @param token Twitter access token + * @param tkn Twitter access token * @return The user found in Twitter * @throws IOException If fails */ - private static Identity fetch(final String token) throws IOException { - final String uri = new Href( - "https://api.twitter.com/1.1/account/verify_credentials.json" - ).with("access_token", token).toString(); - return PsTwitter.parse( - new JdkRequest(uri) + private Identity identity(final String tkn) throws IOException { + return parse( + this.user + .uri() + .set( + URI.create( + new Href(PsTwitter.VERIFY_URL) + .with("access_token", tkn) + .toString() + ) + ) + .back() .header("accept", "application/json") .fetch().as(RestResponse.class) .assertStatus(HttpURLConnection.HTTP_OK) - .as(JsonResponse.class).json().readObject() + .as(JsonResponse.class) + .json() + .readObject() + ); + } + + /** + * Make identity from JSON object. + * @param json JSON received from Twitter + * @return Identity found + */ + private static Identity parse(final JsonObject json) { + final ConcurrentMap props = + new ConcurrentHashMap(json.size()); + props.put("name", json.getString("name")); + props.put("picture", json.getString("profile_image_url")); + return new Identity.Simple( + String.format("urn:twitter:%d", json.getInt("id")), + props ); } /** * Retrieve Twitter access token. - * @return The token + * @return The Twitter access token * @throws IOException If failed */ - private String token() - throws IOException { - final String uri = new Href("https://api.twitter.com/oauth2/token") - .with("grant_type", "client_credentials") - .toString(); - return new JdkRequest(uri) + private String fetch() throws IOException { + return this.token .method("POST") .header( "Content-Type", @@ -133,19 +193,4 @@ private String token() .as(JsonResponse.class) .json().readObject().getString("access_token"); } - - /** - * Make identity from JSON object. - * @param json JSON received from Twitter - * @return Identity found - */ - private static Identity parse(final JsonObject json) { - final ConcurrentMap props = - new ConcurrentHashMap(json.size()); - props.put("name", json.getString("name")); - props.put("picture", json.getString("profile_image_url")); - return new Identity.Simple( - String.format("urn:twitter:%d", json.getInt("id")), props - ); - } } diff --git a/src/main/java/org/takes/rq/RqForm.java b/src/main/java/org/takes/rq/RqForm.java index 281c39685..170936f06 100644 --- a/src/main/java/org/takes/rq/RqForm.java +++ b/src/main/java/org/takes/rq/RqForm.java @@ -37,6 +37,7 @@ import java.util.Locale; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; import lombok.EqualsAndHashCode; import org.takes.HttpException; import org.takes.Request; @@ -68,67 +69,59 @@ public interface RqForm extends Request { * Get single parameter. * @param name Parameter name * @return List of values (can be empty) + * @throws IOException if something fails reading parameters */ - Iterable param(CharSequence name); + Iterable param(CharSequence name) throws IOException; /** * Get all parameter names. * @return All names + * @throws IOException if something fails reading parameters */ - Iterable names(); + Iterable names() throws IOException; /** * Base implementation of @link RqForm. * @author Aleksey Popov (alopen@yandex.ru) * @version $Id$ */ - @EqualsAndHashCode(callSuper = true, of = "map") + @EqualsAndHashCode(callSuper = true, of = "req") final class Base extends RqWrap implements RqForm { + + /** + * Request. + */ + private final transient Request req; + /** - * Map of params and values. + * Saved map. + * @checkstyle LineLengthCheck (3 lines) */ - private final transient ConcurrentMap> map; + private final transient List>> saved = + new CopyOnWriteArrayList>>(); + /** * Ctor. - * @param req Original request + * @param request Original request * @throws IOException If fails */ - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - public Base(final Request req) throws IOException { - super(req); - final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - new RqPrint(req).printBody(baos); - final String body = new String(baos.toByteArray()); - this.map = new ConcurrentHashMap>(0); - for (final String pair : body.split("&")) { - if (pair.isEmpty()) { - continue; - } - final String[] parts = pair.split("=", 2); - if (parts.length < 2) { - throw new HttpException( - HttpURLConnection.HTTP_BAD_REQUEST, - String.format("invalid form body pair: %s", pair) - ); - } - final String key = RqForm.Base.decode( - parts[0].trim().toLowerCase(Locale.ENGLISH) - ); - this.map.putIfAbsent(key, new LinkedList()); - this.map.get(key).add(RqForm.Base.decode(parts[1].trim())); - } + public Base(final Request request) throws IOException { + super(request); + this.req = request; } @Override - public Iterable param(final CharSequence key) { + public Iterable param( + final CharSequence key + ) throws IOException { final List values = - this.map.get(key.toString().toLowerCase(Locale.ENGLISH)); + this.map().get(key.toString().toLowerCase(Locale.ENGLISH)); final Iterable iter; if (values == null) { iter = new VerboseIterable( Collections.emptyList(), new Sprintf( "there are no params \"%s\" among %d others: %s", - key, this.map.size(), this.map.keySet() + key, this.map().size(), this.map().keySet() ) ); } else { @@ -143,8 +136,8 @@ public Iterable param(final CharSequence key) { return iter; } @Override - public Iterable names() { - return this.map.keySet(); + public Iterable names() throws IOException { + return this.map().keySet(); } /** * Decode from URL. @@ -160,6 +153,44 @@ private static String decode(final CharSequence txt) { throw new IllegalStateException(ex); } } + /** + * Create map of request parameter. + * @return Parameters map or empty map in case of error. + * @throws IOException If something fails reading or parsing body + */ + private ConcurrentMap> map() throws IOException { + synchronized (this.saved) { + if (this.saved.isEmpty()) { + final ByteArrayOutputStream + baos = new ByteArrayOutputStream(); + new RqPrint(this.req).printBody(baos); + final String body = new String(baos.toByteArray()); + final ConcurrentMap> map = + new ConcurrentHashMap>(1); + for (final String pair : body.split("&")) { + if (pair.isEmpty()) { + continue; + } + final String[] parts = pair.split("=", 2); + if (parts.length < 2) { + throw new HttpException( + HttpURLConnection.HTTP_BAD_REQUEST, + String.format( + "invalid form body pair: %s", pair + ) + ); + } + final String key = RqForm.Base.decode( + parts[0].trim().toLowerCase(Locale.ENGLISH) + ); + map.putIfAbsent(key, new LinkedList()); + map.get(key).add(RqForm.Base.decode(parts[1].trim())); + } + this.saved.add(map); + } + return this.saved.get(0); + } + } } /** * Smart decorator, with extra features. @@ -183,11 +214,12 @@ public Smart(final RqForm req) { this.origin = req; } @Override - public Iterable param(final CharSequence name) { + public Iterable param(final CharSequence name) + throws IOException { return this.origin.param(name); } @Override - public Iterable names() { + public Iterable names() throws IOException { return this.origin.names(); } @Override @@ -221,8 +253,10 @@ public String single(final CharSequence name) throws IOException { * @param name Name of query param * @param def Default, if not found * @return Value of it + * @throws IOException if something fails reading parameters */ - public String single(final CharSequence name, final String def) { + public String single(final CharSequence name, final String def) + throws IOException { final String value; final Iterator params = this.param(name).iterator(); if (params.hasNext()) { diff --git a/src/main/java/org/takes/rq/RqHeaders.java b/src/main/java/org/takes/rq/RqHeaders.java index 2e8d1f66a..852449ad0 100644 --- a/src/main/java/org/takes/rq/RqHeaders.java +++ b/src/main/java/org/takes/rq/RqHeaders.java @@ -49,6 +49,7 @@ * @version $Id$ * @since 0.1 */ +@SuppressWarnings("PMD.TooManyMethods") public interface RqHeaders extends Request { /** @@ -210,5 +211,25 @@ public String single(final CharSequence name) throws IOException { } return params.next(); } + /** + * If header is present, returns the first header value. + * If not, returns a default value. + * @param name Name of header key + * @param def Default value + * @return Header Value or default value + * @throws IOException If fails + */ + public String single(final CharSequence name, final CharSequence def) + throws IOException { + final String value; + final Iterator params = this.header(name).iterator(); + if (params.hasNext()) { + value = params.next(); + } else { + value = def.toString(); + } + return value; + } + } } diff --git a/src/main/java/org/takes/rs/RsJSON.java b/src/main/java/org/takes/rs/RsJSON.java index b00c1f237..4c563706b 100644 --- a/src/main/java/org/takes/rs/RsJSON.java +++ b/src/main/java/org/takes/rs/RsJSON.java @@ -28,6 +28,7 @@ import java.net.HttpURLConnection; import javax.json.Json; import javax.json.JsonStructure; +import javax.json.JsonWriter; import lombok.EqualsAndHashCode; import org.takes.Response; @@ -89,7 +90,12 @@ public RsJSON(final Response res) { */ private static byte[] print(final RsJSON.Source src) throws IOException { final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - Json.createWriter(baos).write(src.toJSON()); + final JsonWriter writer = Json.createWriter(baos); + try { + writer.write(src.toJSON()); + } finally { + writer.close(); + } return baos.toByteArray(); } diff --git a/src/main/java/org/takes/rs/RsPrettyJSON.java b/src/main/java/org/takes/rs/RsPrettyJSON.java new file mode 100644 index 000000000..e7cab776a --- /dev/null +++ b/src/main/java/org/takes/rs/RsPrettyJSON.java @@ -0,0 +1,130 @@ +/** + * The MIT License (MIT) + * + * Copyright (c) 2015 Yegor Bugayenko + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.takes.rs; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.json.Json; +import javax.json.JsonException; +import javax.json.JsonObject; +import javax.json.JsonReader; +import javax.json.JsonWriter; +import javax.json.stream.JsonGenerator; +import lombok.EqualsAndHashCode; +import org.takes.Response; + +/** + * Response with properly indented JSON body. + * + *

The class is immutable and thread-safe. + * + * @author Eugene Kondrashev (eugene.kondrashev@gmail.com) + * @version $Id$ + * @since 1.0 + */ +@EqualsAndHashCode(of = "origin") +public final class RsPrettyJSON implements Response { + + /** + * Original response. + */ + private final transient Response origin; + + /** + * Response with properly transformed body. + */ + private final transient List transformed = + new CopyOnWriteArrayList(); + + /** + * Ctor. + * @param res Original response + */ + public RsPrettyJSON(final Response res) { + this.origin = res; + } + + @Override + public Iterable head() throws IOException { + return this.make().head(); + } + + @Override + public InputStream body() throws IOException { + return this.make().body(); + } + + /** + * Make a response. + * @return Response just made + * @throws IOException If fails + */ + private Response make() throws IOException { + synchronized (this.transformed) { + if (this.transformed.isEmpty()) { + this.transformed.add( + new RsWithBody( + this.origin, + RsPrettyJSON.transform(this.origin.body()) + ) + ); + } + } + return this.transformed.get(0); + } + + /** + * Format body with proper indents. + * @param body Response body + * @return New properly formatted body + * @throws IOException If fails + */ + private static byte[] transform(final InputStream body) throws IOException { + final ByteArrayOutputStream res = new ByteArrayOutputStream(); + final JsonReader rdr = Json.createReader(body); + try { + final JsonObject obj = rdr.readObject(); + final JsonWriter wrt = Json.createWriterFactory( + Collections.singletonMap( + JsonGenerator.PRETTY_PRINTING, + true + ) + ).createWriter(res); + try { + wrt.writeObject(obj); + } finally { + wrt.close(); + } + } catch (final JsonException ex) { + throw new IOException(ex); + } finally { + rdr.close(); + } + return res.toByteArray(); + } +} diff --git a/src/main/java/org/takes/rs/xe/XeStylesheet.java b/src/main/java/org/takes/rs/xe/XeStylesheet.java index 5def98fb9..b2d0b4670 100644 --- a/src/main/java/org/takes/rs/xe/XeStylesheet.java +++ b/src/main/java/org/takes/rs/xe/XeStylesheet.java @@ -35,12 +35,25 @@ * @author Yegor Bugayenko (yegor@teamed.io) * @version $Id$ * @since 0.1 + * @link XML+XSLT in a Browser + * @link RESTful API and a Web Site in the Same URL */ @EqualsAndHashCode(callSuper = true) public final class XeStylesheet extends XeWrap { /** * Ctor. + * + *

The only argument here is a location of XSL stylesheet, + * in resources and, at the same time, in HTTP. For example, you + * can put "/xsl/main.xsl" there. In the XML output this will generate + * an "xml-stylesheet" annotation, which will point the browser + * to "http://yoursite/xsl/main.xsl". Obviously, this page must + * be available. On the other hand, if you're using RsXSLT, this + * file must be available in resources as "/xsl/main.xsl". + * + *

It is recommended to put XSL files under "src/main/resources/xsl". + * * @param xsl XSL stylesheet */ public XeStylesheet(final CharSequence xsl) { diff --git a/src/main/java/org/takes/tk/TkCORS.java b/src/main/java/org/takes/tk/TkCORS.java new file mode 100644 index 000000000..9357c24e8 --- /dev/null +++ b/src/main/java/org/takes/tk/TkCORS.java @@ -0,0 +1,103 @@ +/** + * The MIT License (MIT) + * + * Copyright (c) 2015 Yegor Bugayenko + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.takes.tk; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import lombok.EqualsAndHashCode; +import org.takes.Request; +import org.takes.Response; +import org.takes.Take; +import org.takes.rq.RqHeaders; +import org.takes.rs.RsWithHeaders; +import org.takes.rs.RsWithStatus; + +/** + * CORS take. + * + *

This take checks if the request (Origin) is allowed to perform + * the desired action against the list of the given domains.

+ * + *

The specification of CORS can be found on the W3C web site on the + * following link or even on the RFC-6454 specification. + * + * @author Endrigo Antonini (teamed@endrigo.com.br) + * @version $Id$ + * @since 0.20 + */ +@EqualsAndHashCode(of = { "origin" , "allowed" }) +public final class TkCORS implements Take { + + /** + * Original take. + */ + private final transient Take origin; + + /** + * List of allowed domains. + */ + private final transient Set allowed; + + /** + * Ctor. + * @param take Original + * @param domains Allow domains + */ + public TkCORS(final Take take, final String... domains) { + this.origin = take; + this.allowed = new HashSet(Arrays.asList(domains)); + } + + @Override + public Response act(final Request req) throws IOException { + final Response response; + final String domain = new RqHeaders.Smart( + new RqHeaders.Base(req) + ).single("origin", ""); + if (this.allowed.contains(domain)) { + response = new RsWithHeaders( + this.origin.act(req), + "Access-Control-Allow-Credentials: true", + // @checkstyle LineLengthCheck (1 line) + "Access-Control-Allow-Methods: OPTIONS, GET, PUT, POST, DELETE, HEAD", + String.format( + "Access-Control-Allow-Origin: %s", + domain + ) + ); + } else { + response = new RsWithHeaders( + new RsWithStatus( + HttpURLConnection.HTTP_FORBIDDEN + ), + "Access-Control-Allow-Credentials: false" + ); + } + return response; + } +} diff --git a/src/test/java/org/takes/facets/auth/PsBasicTest.java b/src/test/java/org/takes/facets/auth/PsBasicTest.java new file mode 100644 index 000000000..50bc3f453 --- /dev/null +++ b/src/test/java/org/takes/facets/auth/PsBasicTest.java @@ -0,0 +1,212 @@ +/** + * The MIT License (MIT) + * + * Copyright (c) 2015 Yegor Bugayenko + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.takes.facets.auth; + +import javax.xml.bind.DatatypeConverter; +import org.apache.commons.lang.RandomStringUtils; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Test; +import org.takes.HttpException; +import org.takes.facets.forward.RsForward; +import org.takes.misc.Opt; +import org.takes.rq.RqFake; +import org.takes.rq.RqMethod; +import org.takes.rq.RqWithHeaders; +import org.takes.rs.RsPrint; + +/** + * Test of {@link PsBasic}. + * @author Endrigo Antonini (teamed@endrigo.com.br) + * @version $Id$ + * @since 0.20 + */ +public final class PsBasicTest { + + /** + * Basic Auth. + */ + private static final String AUTH_BASIC = "Authorization: Basic %s"; + + /** + * PsBasic can handle connection with valid credential. + * @throws Exception if any error occurs + */ + @Test + public void handleConnectionWithValidCredential() throws Exception { + final String user = "john"; + final Opt identity = new PsBasic( + "RealmA", + new PsBasic.Fake(true) + ).enter( + new RqWithHeaders( + new RqFake( + RqMethod.GET, + String.format( + "?valid_code=%s", + // @checkstyle MagicNumberCheck (1 line) + RandomStringUtils.randomAlphanumeric(10) + ) + ), + this.generateAuthenticateHead(user, "pass") + ) + ); + MatcherAssert.assertThat(identity.has(), Matchers.is(true)); + MatcherAssert.assertThat( + identity.get().urn(), + CoreMatchers.equalTo(this.generateIdentityUrn(user)) + ); + } + + /** + * PsBasic can handle connection with invalid credential. + * @throws Exception If some problem inside + */ + @Test + public void handleConnectionWithInvalidCredential() throws Exception { + RsForward forward = new RsForward(); + try { + new PsBasic( + "RealmB", + new PsBasic.Empty() + ).enter( + new RqWithHeaders( + new RqFake( + RqMethod.GET, + String.format( + "?invalid_code=%s", + // @checkstyle MagicNumberCheck (1 line) + RandomStringUtils.randomAlphanumeric(10) + ) + ), + this.generateAuthenticateHead("username", "wrong") + ) + ); + } catch (final RsForward ex) { + forward = ex; + } + MatcherAssert.assertThat( + new RsPrint(forward).printHead(), + Matchers.allOf( + Matchers.containsString("HTTP/1.1 401 Unauthorized"), + Matchers.containsString( + "WWW-Authenticate: Basic ream=\"RealmB\"" + ) + ) + ); + } + + /** + * PsBasic can handle multiple headers with valid credential. + * @throws Exception If some problem inside + */ + @Test + public void handleMultipleHeadersWithValidCredential() throws Exception { + final String user = "bill"; + final Opt identity = new PsBasic( + "RealmC", + new PsBasic.Fake(true) + ).enter( + new RqWithHeaders( + new RqFake( + RqMethod.GET, + String.format( + "?multiple_code=%s", + // @checkstyle MagicNumberCheck (1 line) + RandomStringUtils.randomAlphanumeric(10) + ) + ), + this.generateAuthenticateHead(user, "changeit"), + "Referer: http://teamed.io/", + "Connection:keep-alive", + "Content-Encoding:gzip", + "X-Check-Cacheable:YES", + "X-Powered-By:Java/1.7" + ) + ); + MatcherAssert.assertThat(identity.has(), Matchers.is(true)); + MatcherAssert.assertThat( + identity.get().urn(), + CoreMatchers.equalTo(this.generateIdentityUrn(user)) + ); + } + + /** + * PsBasic can handle multiple headers with invalid content. + * @throws Exception If some problem inside + */ + @Test(expected = HttpException.class) + public void handleMultipleHeadersWithInvalidContent() throws Exception { + MatcherAssert.assertThat( + new PsBasic( + "RealmD", + new PsBasic.Fake(true) + ).enter( + new RqWithHeaders( + new RqFake( + "XPTO", + "/wrong-url" + ), + String.format( + "XYZ%s", + this.generateAuthenticateHead("user", "password") + ), + "XYZReferer: http://teamed.io/", + "XYZConnection:keep-alive", + "XYZContent-Encoding:gzip", + "XYZX-Check-Cacheable:YES", + "XYZX-Powered-By:Java/1.7" + ) + ).has() + , Matchers.is(false) + ); + } + + /** + * Generate the identity urn. + * @param user User + * @return URN + */ + private String generateIdentityUrn(final String user) { + return String.format("urn:basic:%s", user); + } + /** + * Generate the string used on the request that store information about + * authentication. + * @param user Username + * @param pass Password + * @return Header string. + */ + private String generateAuthenticateHead( + final String user, + final String pass + ) { + final String auth = String.format("%s:%s", user, pass); + final String encoded = DatatypeConverter.printBase64Binary( + auth.getBytes() + ); + return String.format(AUTH_BASIC, encoded); + } +} diff --git a/src/test/java/org/takes/facets/auth/social/PsTwitterTest.java b/src/test/java/org/takes/facets/auth/social/PsTwitterTest.java index ae854a68b..729587cb6 100644 --- a/src/test/java/org/takes/facets/auth/social/PsTwitterTest.java +++ b/src/test/java/org/takes/facets/auth/social/PsTwitterTest.java @@ -24,31 +24,79 @@ package org.takes.facets.auth.social; +import com.jcabi.http.request.FakeRequest; import java.io.IOException; -import org.junit.Ignore; +import java.util.Collections; +import java.util.Map; +import javax.json.Json; +import org.apache.commons.lang.RandomStringUtils; +import org.apache.commons.lang.math.RandomUtils; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; import org.junit.Test; +import org.takes.facets.auth.Identity; +import org.takes.facets.auth.Pass; +import org.takes.rq.RqFake; /** * Test case for {@link PsTwitter}. * @author Prasath Premkumar (popprem@gmail.com) + * @author Adam Siemion (adam.siemion@lemonsoftware.pl) * @version $Id$ * @since 1.0 + * @checkstyle MagicNumberCheck (500 lines) + * @checkstyle MultipleStringLiteralsCheck (500 lines) */ public final class PsTwitterTest { /** - * Twitter authorization process. + * PsTwitter can login. * @throws IOException If error occurs in the process - * @todo #11:30min/DEV Test to be implemented for PsTwitter - * using a oauth mock library (eg:wiremock). Need to modify - * PsTwitter to accept url from configurations, so that url - * can be changed for test and real env accordingly. - * Response to be stubbed for both token and verify_credentials - * calls and assertions to be performed for the values returned. */ - @Ignore @Test - public void authorizes() throws IOException { - throw new UnsupportedOperationException("not implemented yet"); + public void logsIn() throws IOException { + final int tid = RandomUtils.nextInt(1000); + final String name = RandomStringUtils.randomAlphanumeric(10); + final String picture = RandomStringUtils.randomAlphanumeric(10); + final Pass pass = new PsTwitter( + new FakeRequest( + 200, + "HTTP OK", + Collections.>emptyList(), + String.format( + "{\"token_type\":\"bearer\",\"access_token\":\"%s\"}", + RandomStringUtils.randomAlphanumeric(10) + ).getBytes() + ), + new FakeRequest( + 200, + "HTTP OK", + Collections.>emptyList(), + Json.createObjectBuilder() + .add("id", tid) + .add("name", name) + .add("profile_image_url", picture) + .build() + .toString() + .getBytes() + ), + RandomStringUtils.randomAlphanumeric(10), + RandomStringUtils.randomAlphanumeric(10) + ); + final Identity identity = pass.enter( + new RqFake("GET", "") + ).get(); + MatcherAssert.assertThat( + identity.urn(), + CoreMatchers.equalTo(String.format("urn:twitter:%d", tid)) + ); + MatcherAssert.assertThat( + identity.properties().get("name"), + CoreMatchers.equalTo(name) + ); + MatcherAssert.assertThat( + identity.properties().get("picture"), + CoreMatchers.equalTo(picture) + ); } } diff --git a/src/test/java/org/takes/rq/RqFormTest.java b/src/test/java/org/takes/rq/RqFormTest.java index 5947f4b09..fe7fd67a4 100644 --- a/src/test/java/org/takes/rq/RqFormTest.java +++ b/src/test/java/org/takes/rq/RqFormTest.java @@ -64,4 +64,26 @@ public void parsesHttpBody() throws IOException { ); } + /** + * Returns always same instances (Cache). + * @throws IOException if fails + */ + @Test + public void sameInstance() throws IOException { + final RqForm req = new RqForm.Base( + new RqBuffered( + new RqFake( + Arrays.asList( + "GET /path?a=3", + "Host: www.example2.com" + ), + "alpha=a+b+c&beta=%20No%20" + ) + ) + ); + MatcherAssert.assertThat( + req.names() == req.names(), + Matchers.is(Boolean.TRUE) + ); + } } diff --git a/src/test/java/org/takes/rq/RqHeadersTest.java b/src/test/java/org/takes/rq/RqHeadersTest.java index e76ac0a91..6cced3d30 100644 --- a/src/test/java/org/takes/rq/RqHeadersTest.java +++ b/src/test/java/org/takes/rq/RqHeadersTest.java @@ -80,4 +80,48 @@ public void findsAllHeaders() throws IOException { ); } + /** + * RqHeaders.Smart can return a single header. + * @throws IOException If some problem inside + */ + @Test + public void returnsSingleHeader() throws IOException { + MatcherAssert.assertThat( + new RqHeaders.Smart( + new RqHeaders.Base( + new RqFake( + Arrays.asList( + "GET /g", + "Host: www.takes.com" + ), + "" + ) + ) + ).single("host", "www.takes.net"), + Matchers.equalTo("www.takes.com") + ); + } + /** + * RqHeaders.Smart can return a default header. + * @throws IOException If some problem inside + */ + @Test + public void returnsDefaultHeader() throws IOException { + final String type = "text/plain"; + MatcherAssert.assertThat( + new RqHeaders.Smart( + new RqHeaders.Base( + new RqFake( + Arrays.asList( + "GET /f", + "Accept: text/json" + ), + "" + ) + ) + ).single("Content-type", type), + Matchers.equalTo(type) + ); + } + } diff --git a/src/test/java/org/takes/rs/RsJSONTest.java b/src/test/java/org/takes/rs/RsJSONTest.java index d6d734c98..e8d2a7965 100644 --- a/src/test/java/org/takes/rs/RsJSONTest.java +++ b/src/test/java/org/takes/rs/RsJSONTest.java @@ -25,6 +25,7 @@ import java.io.IOException; import javax.json.Json; +import javax.json.JsonArrayBuilder; import javax.json.JsonStructure; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; @@ -56,4 +57,25 @@ public void buildsJsonResponse() throws IOException { ); } + /** + * RsJSON can build a big JSON response. + * @throws IOException If some problem inside + */ + @Test + public void buildsBigJsonResponse() throws IOException { + final int size = 100000; + final JsonArrayBuilder builder = Json.createArrayBuilder(); + for (int idx = 0; idx < size; ++idx) { + builder.add( + Json.createObjectBuilder().add("number", "212 555-1234") + ); + } + MatcherAssert.assertThat( + Json.createReader( + new RsJSON(builder.build()).body() + ).readArray().size(), + Matchers.equalTo(size) + ); + } + } diff --git a/src/test/java/org/takes/rs/RsPrettyJSONTest.java b/src/test/java/org/takes/rs/RsPrettyJSONTest.java new file mode 100644 index 000000000..73e7ce4b5 --- /dev/null +++ b/src/test/java/org/takes/rs/RsPrettyJSONTest.java @@ -0,0 +1,107 @@ +/** + * The MIT License (MIT) + * + * Copyright (c) 2015 Yegor Bugayenko + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.takes.rs; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import nl.jqno.equalsverifier.EqualsVerifier; +import nl.jqno.equalsverifier.Warning; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Test; + +/** + * Test case for {@link RsPrettyJSON}. + * @author Eugene Kondrashev (eugene.kondrashev@gmail.com) + * @version $Id$ + * @since 1.0 + */ +public final class RsPrettyJSONTest { + + /** + * RsPrettyJSON can format response with JSON body. + * @throws Exception If some problem inside + */ + @Test + public void formatsJsonBody() throws Exception { + MatcherAssert.assertThat( + new RsPrint( + new RsPrettyJSON( + new RsWithBody("{\"widget\": {\"debug\": \"on\" }}") + ) + ).printBody(), + Matchers.is( + "\n{\n \"widget\":{\n \"debug\":\"on\"\n }\n}" + ) + ); + } + + /** + * RsPrettyJSON can reject a non-JSON body. + * @throws Exception If some problem inside + */ + @Test(expected = IOException.class) + public void rejectsNonJsonBody() throws Exception { + new RsPrint(new RsPrettyJSON(new RsWithBody("foo"))).printBody(); + } + + /** + * RsPrettyJSON can report correct content length. + * @throws Exception If some problem inside + */ + @Test + public void reportsCorrectContentLength() throws Exception { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + new RsPrint( + new RsWithBody( + "\n{\n \"test\":{\n \"test\":\"test\"\n }\n}" + ) + ).printBody(baos); + MatcherAssert.assertThat( + new RsPrint( + new RsPrettyJSON( + new RsWithBody("{\"test\": {\"test\": \"test\" }}") + ) + ).printHead(), + Matchers.containsString( + String.format( + "Content-Length: %d", + baos.toByteArray().length + ) + ) + ); + } + + /** + * RsPrettyJSON can conform to equals and hash code contract. + * @throws Exception If some problem inside + */ + @Test + public void conformsToEqualsAndHashCode() throws Exception { + EqualsVerifier.forClass(RsPrettyJSON.class) + .suppress(Warning.TRANSIENT_FIELDS) + .withRedefinedSuperclass() + .verify(); + } +} diff --git a/src/test/java/org/takes/tk/TkCORSTest.java b/src/test/java/org/takes/tk/TkCORSTest.java new file mode 100644 index 000000000..98896e25c --- /dev/null +++ b/src/test/java/org/takes/tk/TkCORSTest.java @@ -0,0 +1,112 @@ +/** + * The MIT License (MIT) + * + * Copyright (c) 2015 Yegor Bugayenko + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.takes.tk; + +import java.net.HttpURLConnection; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Test; +import org.takes.facets.hamcrest.HmRsStatus; +import org.takes.rq.RqFake; +import org.takes.rq.RqWithHeaders; +import org.takes.rs.RsPrint; +import org.takes.rs.RsText; + +/** + * Test case for {@link TkCORS}. + * @author Endrigo Antonini (teamed@endrigo.com.br) + * @version $Id$ + * @since 0.20 + */ +public final class TkCORSTest { + + /** + * TkCORS can handle connections without origin in the request. + * @throws Exception If some problem inside + */ + @Test + public void handleConnectionsWithoutOriginInTheRequest() throws Exception { + MatcherAssert.assertThat( + "It was expected to receive a 403 error.", + new TkCORS( + new TkFixed(new RsText()), + "http://www.netbout.io", + "http://www.example.com" + ).act(new RqFake()), + new HmRsStatus(Matchers.equalTo(HttpURLConnection.HTTP_FORBIDDEN)) + ); + } + + /** + * TkCORS can handle connections with correct domain on origin. + * @throws Exception If some problem inside + */ + @Test + public void handleConnectionsWithCorrectDomainOnOrigin() throws Exception { + MatcherAssert.assertThat( + "Invalid HTTP status for a request with correct domain.", + new TkCORS( + new TkFixed(new RsText()), + "http://teamed.io", + "http://example.com" + ).act( + new RqWithHeaders( + new RqFake(), + "Origin: http://teamed.io" + ) + ), + new HmRsStatus(Matchers.equalTo(HttpURLConnection.HTTP_OK)) + ); + } + + /** + * TkCors can't handle connections with wrong domain on origin. + * @throws Exception If some problem inside + */ + @Test + public void cantHandleConnectionsWithWrongDomainOnOrigin() + throws Exception { + MatcherAssert.assertThat( + "Wrong value on header.", + new RsPrint( + new TkCORS( + new TkFixed(new RsText()), + "http://www.teamed.io", + "http://sample.com" + ).act( + new RqWithHeaders( + new RqFake(), + "Origin: http://wrong.teamed.io" + ) + ) + ).printHead(), + Matchers.allOf( + Matchers.containsString("HTTP/1.1 403"), + Matchers.containsString( + "Access-Control-Allow-Credentials: false" + ) + ) + ); + } +}