diff --git a/build.gradle b/build.gradle index 23c6e1d..2535748 100644 --- a/build.gradle +++ b/build.gradle @@ -20,10 +20,11 @@ dependencies { compile 'ch.qos.logback:logback-classic:1.0.1' compile 'ch.qos.logback:logback-core:1.0.1' + compile 'com.google.guava:guava:21.0' + compileOnly "org.projectlombok:lombok:1.16.10" testCompile 'org.spockframework:spock-core:1.0-groovy-2.4' testCompile 'org.codehaus.groovy:groovy:2.4.7' testCompile 'cglib:cglib:3.2.2' - } diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/your_github_login/LICENSE-template.txt b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/LICENSE.txt similarity index 95% rename from src/main/java/com/softwaremill/java_fp_example/contest/your_github_login/LICENSE-template.txt rename to src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/LICENSE.txt index df01d55..551d4b8 100644 --- a/src/main/java/com/softwaremill/java_fp_example/contest/your_github_login/LICENSE-template.txt +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 +Copyright (c) 2017 mszarlinski Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/your_github_login/Readme.txt b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/Readme.txt similarity index 100% rename from src/main/java/com/softwaremill/java_fp_example/contest/your_github_login/Readme.txt rename to src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/Readme.txt diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupElement.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupElement.java new file mode 100644 index 0000000..8634b34 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupElement.java @@ -0,0 +1,41 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.adapter.jsoup; + +import com.google.common.base.Strings; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.Element; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.Elements; +import javaslang.collection.List; +import javaslang.control.Option; + +class JsoupElement implements Element { + + private final org.jsoup.nodes.Element wrapped; + + static Element from(org.jsoup.nodes.Element element) { + return new JsoupElement(element); + } + + private JsoupElement(org.jsoup.nodes.Element element) { + this.wrapped = element; + } + + @Override + public boolean hasAttr(String attrName, String attrValue) { + return attr(attrName) + .filter(val -> val.equals(attrValue)) + .isDefined(); + } + + @Override + public Option attr(String attrName) { + return Option.of(wrapped.attr(attrName)) + .filter(s -> !Strings.isNullOrEmpty(s)); + } + + @Override + public Elements childrenByTag(String tagName) { + return JsoupElements.from(wrapped.getElementsByTag(tagName) + .stream() + .map(JsoupElement::new) + .collect(List.collector())); + } +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupElements.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupElements.java new file mode 100644 index 0000000..82f7f48 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupElements.java @@ -0,0 +1,37 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.adapter.jsoup; + +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.DummyElement; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.DummyElements; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.Element; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.Elements; +import javaslang.collection.List; + +class JsoupElements implements Elements { + + private final List wrapped; + + static Elements from(List elements) { + if (elements.isEmpty()) { + return DummyElements.INSTANCE; + } else { + return new JsoupElements(elements); + } + } + + private JsoupElements(List wrapped) { + this.wrapped = wrapped; + } + + @Override + public Element first() { + return wrapped.headOption() + .map(h -> (Element) h) + .getOrElse(DummyElement.INSTANCE); + } + + @Override + public Elements filterByAttribute(String attrName, String attrValue) { + return JsoupElements.from(wrapped.filter(e -> e.hasAttr(attrName, attrValue))); + } + +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupWebPage.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupWebPage.java new file mode 100644 index 0000000..6fb0c45 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupWebPage.java @@ -0,0 +1,25 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.adapter.jsoup; + +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.DummyElement; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.Element; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.WebPage; +import javaslang.control.Option; +import org.jsoup.nodes.Document; + +class JsoupWebPage implements WebPage { + + private final Document wrapped; + + JsoupWebPage(Document document) { + this.wrapped = document; + } + + @Override + public Element head() { + return Option.of(wrapped.head()) + .map(JsoupElement::from) + .getOrElse(DummyElement.INSTANCE); + } + + +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupWebPageLoader.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupWebPageLoader.java new file mode 100644 index 0000000..03cfe7e --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/adapter/jsoup/JsoupWebPageLoader.java @@ -0,0 +1,23 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.adapter.jsoup; + +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.WebPageLoader; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.WebPage; +import javaslang.control.Try; +import org.jsoup.Jsoup; + +import java.net.URL; +import java.time.Duration; + +class JsoupWebPageLoader implements WebPageLoader { + + private final int webPageLoadingTimeoutMillis; + + JsoupWebPageLoader(Duration webPageLoadingTimeout) { + this.webPageLoadingTimeoutMillis = (int) webPageLoadingTimeout.toMillis(); + } + + @Override + public Try tryLoadWebPage(String url) { + return Try.of(() -> new JsoupWebPage(Jsoup.parse(new URL(url), webPageLoadingTimeoutMillis))); + } +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/FacebookImage.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/FacebookImage.java new file mode 100644 index 0000000..c293175 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/FacebookImage.java @@ -0,0 +1,44 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.domain; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; + +/** + * Immutable value object representing a FB image. + */ +public class FacebookImage { + + public final static String FACEBOOK_IMAGE_TAG = "og:image"; + + public static final FacebookImage DEFAULT = new FacebookImage("https://softwaremill.com/images/logo-vertical.023d8496.png"); + + private final String url; + + public FacebookImage(String url) { + this.url = url; + } + + public String getUrl() { + return url; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FacebookImage that = (FacebookImage) o; + return Objects.equal(url, that.url); + } + + @Override + public int hashCode() { + return Objects.hashCode(url); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("url", url) + .toString(); + } +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/FacebookImageProvider.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/FacebookImageProvider.java new file mode 100644 index 0000000..86d1113 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/FacebookImageProvider.java @@ -0,0 +1,49 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.domain; + +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.Element; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.Elements; +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.WebPage; +import lombok.extern.slf4j.Slf4j; + +import java.util.function.Consumer; +import java.util.function.Function; + +import static java.lang.String.format; + +/** + * Component responsible for extracting FB image from given page. + */ +@Slf4j +public class FacebookImageProvider { + + private final WebPageLoader webPageLoader; + + public FacebookImageProvider(WebPageLoader webPageLoader) { + this.webPageLoader = webPageLoader; + } + + public FacebookImage getImageUrlFromPage(String pageUrl) { + return webPageLoader.tryLoadWebPage(pageUrl) + .map(WebPage::head) + .map(metasWithImage()) + .map(Elements::first) + .map(fbImage()) + .onFailure(logError(pageUrl)) + .getOrElse(FacebookImage.DEFAULT); + } + + private Function metasWithImage() { + return h -> h.childrenByTag("meta") + .filterByAttribute("property", FacebookImage.FACEBOOK_IMAGE_TAG); + } + + private Function fbImage() { + return e -> e.attr("content") + .map(FacebookImage::new) + .getOrElse(FacebookImage.DEFAULT); + } + + private Consumer logError(String pageUrl) { + return t -> log.warn(format("Failed to extract image from %s", pageUrl), t); + } +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/WebPageLoader.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/WebPageLoader.java new file mode 100644 index 0000000..bf92dc0 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/WebPageLoader.java @@ -0,0 +1,8 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.domain; + +import com.softwaremill.java_fp_example.contest.mszarlinski.domain.web.WebPage; +import javaslang.control.Try; + +public interface WebPageLoader { + Try tryLoadWebPage(String url); +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/DummyElement.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/DummyElement.java new file mode 100644 index 0000000..73732c4 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/DummyElement.java @@ -0,0 +1,23 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.domain.web; + +import javaslang.control.Option; + +public class DummyElement implements Element { + + public static final Element INSTANCE = new DummyElement(); + + @Override + public boolean hasAttr(String attrName, String attrValue) { + return false; + } + + @Override + public Option attr(String attrName) { + return Option.none(); + } + + @Override + public Elements childrenByTag(String tagName) { + return DummyElements.INSTANCE; + } +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/DummyElements.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/DummyElements.java new file mode 100644 index 0000000..0a1b552 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/DummyElements.java @@ -0,0 +1,16 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.domain.web; + +public class DummyElements implements Elements { + + public static final Elements INSTANCE = new DummyElements(); + + @Override + public Element first() { + return DummyElement.INSTANCE; + } + + @Override + public Elements filterByAttribute(String attrName, String attrValue) { + return this; + } +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/Element.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/Element.java new file mode 100644 index 0000000..cc41dc9 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/Element.java @@ -0,0 +1,11 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.domain.web; + +import javaslang.control.Option; + +public interface Element { + boolean hasAttr(String attrName, String attrValue); + + Option attr(String attrName); + + Elements childrenByTag(String tagName); +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/Elements.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/Elements.java new file mode 100644 index 0000000..237196f --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/Elements.java @@ -0,0 +1,8 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.domain.web; + +public interface Elements { + + Element first(); + + Elements filterByAttribute(String attrName, String attrValue); +} diff --git a/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/WebPage.java b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/WebPage.java new file mode 100644 index 0000000..55d7236 --- /dev/null +++ b/src/main/java/com/softwaremill/java_fp_example/contest/mszarlinski/domain/web/WebPage.java @@ -0,0 +1,6 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.domain.web; + +public interface WebPage { + + Element head(); +} diff --git a/src/test/groovy/com/softwaremill/java_fp_example/contest/mszarlinski/domain/FacebookImageProviderSpec.groovy b/src/test/groovy/com/softwaremill/java_fp_example/contest/mszarlinski/domain/FacebookImageProviderSpec.groovy new file mode 100644 index 0000000..746c0d6 --- /dev/null +++ b/src/test/groovy/com/softwaremill/java_fp_example/contest/mszarlinski/domain/FacebookImageProviderSpec.groovy @@ -0,0 +1,32 @@ +package com.softwaremill.java_fp_example.contest.mszarlinski.domain + +import com.softwaremill.java_fp_example.contest.mszarlinski.adapter.jsoup.JsoupWebPageLoader +import spock.lang.Specification +import spock.lang.Unroll + +import java.time.Duration + +import static com.softwaremill.java_fp_example.DefaultImage.DEFAULT_IMAGE + +class FacebookImageProviderSpec extends Specification { + + @Unroll + def "should test Provider version with address #postAddress"() { + given: + FacebookImageProvider provider = new FacebookImageProvider( + new JsoupWebPageLoader(Duration.ofSeconds(10))) + + when: + def image = provider.getImageUrlFromPage(postAddress) + + then: + image.url == expectedImageUrl + + where: + postAddress || expectedImageUrl + "https://softwaremill.com/the-wrong-abstraction-recap/" || "https://softwaremill.com/images/uploads/2017/02/street-shoe-chewing-gum.0526d557.jpg" + "https://softwaremill.com/using-kafka-as-a-message-queue/" || "https://softwaremill.com/images/uploads/2017/02/kmq.93f842cf.png" + "https://twitter.com/softwaremill" || DEFAULT_IMAGE + "http://i-do-not-exist.pl" || DEFAULT_IMAGE + } +}