diff --git a/examples/spotify-api-example/.gitignore b/examples/spotify-api-example/.gitignore new file mode 100644 index 000000000..9f11b755a --- /dev/null +++ b/examples/spotify-api-example/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/examples/spotify-api-example/README.md b/examples/spotify-api-example/README.md new file mode 100644 index 000000000..fec687e85 --- /dev/null +++ b/examples/spotify-api-example/README.md @@ -0,0 +1,28 @@ +## A simple example processing data from the Spotify API + +### Build +`mvn package` + +### Run +`java -jar target/spotify-api-example-service.jar` + +### Call +``` +$ http :8080/albums/new +HTTP/1.1 200 OK +Content-Length: 1364 +Date: Thu, 03 Dec 2015 16:30:18 GMT +Server: Jetty(9.3.4.v20151007) + +[{"name":"Hör vad du säger men jag har glömt vad du sa","artist":{"name":"Danny Saucedo"}},{"name":"The Only One (Kleerup Remix)","artist":{"name":"Miriam Bryant"}},{"name":"Broken Arrows (Remixes)","artist":{"name":"Avicii"}},{"name":"Handwritten (Revisited)","artist":{"name":"Shawn Mendes"}},{"name":"Jag går nu","artist":{"name":"Melissa Horn"}},{"name":"Bang My Head (feat. Sia & Fetty Wap)","artist":{"name":"David Guetta"}},{"name":"Handwritten (Revisited)","artist":{"name":"Shawn Mendes"}},{"name":"Everglow","artist":{"name":"Coldplay"}},{"name":"Jag hör vad du säger men glömt vad du sa","artist":{"name":"Danny Saucedo"}},{"name":"Regissören","artist":{"name":"Dani M"}},{"name":"Peace Is The Mission: Extended","artist":{"name":"Major Lazer"}},{"name":"Stay","artist":{"name":"Kygo"}},{"name":"Fine By Me","artist":{"name":"Chris Brown"}},{"name":"Be Together","artist":{"name":"Major Lazer"}},{"name":"Peace Is The Mission (Extended)","artist":{"name":"Major Lazer"}},{"name":"Peace Is The Mission: Extended","artist":{"name":"Major Lazer"}},{"name":"Peace Is The Mission: Extended","artist":{"name":"Major Lazer"}},{"name":"Peace is the Mission: Extended","artist":{"name":"Major Lazer"}},{"name":"Peace Is The Mission : Extended","artist":{"name":"Major Lazer"}},{"name":"Adventure Of A Lifetime (Radio Edit)","artist":{"name":"Coldplay"}}] +``` + +``` +$ http :8080/artists/toptracks/se?q=elvis +HTTP/1.1 200 OK +Content-Length: 1110 +Date: Thu, 03 Dec 2015 16:31:29 GMT +Server: Jetty(9.3.4.v20151007) + +[{"name":"Can't Help Falling in Love","album":{"name":"Blue Hawaii","artist":{"name":"Elvis Presley"}}},{"name":"Blue Christmas","album":{"name":"Elvis' Christmas Album","artist":{"name":"Elvis Presley"}}},{"name":"Jailhouse Rock","album":{"name":"Elvis' Golden Records","artist":{"name":"Elvis Presley"}}},{"name":"Suspicious Minds","album":{"name":"Back In Memphis","artist":{"name":"Elvis Presley"}}},{"name":"A Little Less Conversation - JXL Radio Edit Remix","album":{"name":"Elvis 75 - Good Rockin' Tonight","artist":{"name":"Elvis Presley"}}},{"name":"Always on My Mind - Remastered","album":{"name":"The Essential Elvis Presley","artist":{"name":"Elvis Presley"}}},{"name":"Here Comes Santa Claus (Right Down Santa Claus Lane)","album":{"name":"Elvis' Christmas Album","artist":{"name":"Elvis Presley"}}},{"name":"In the Ghetto","album":{"name":"From Elvis In Memphis","artist":{"name":"Elvis Presley"}}},{"name":"Hound Dog","album":{"name":"Elvis' Golden Records","artist":{"name":"Elvis Presley"}}},{"name":"Don't Be Cruel","album":{"name":"Elvis' Golden Records","artist":{"name":"Elvis Presley"}}}] +``` diff --git a/examples/spotify-api-example/pom.xml b/examples/spotify-api-example/pom.xml new file mode 100644 index 000000000..387b29579 --- /dev/null +++ b/examples/spotify-api-example/pom.xml @@ -0,0 +1,108 @@ + + + 4.0.0 + + Spotify API Example + An Apollo example application using the Spotify API + com.spotify + spotify-api-example-service + 0.0.1-SNAPSHOT + jar + + + + + com.spotify + apollo-bom + 1.0.0 + pom + import + + + + + + + com.spotify + apollo-http-service + + + + ch.qos.logback + logback-classic + 1.1.3 + + + + + ${project.artifactId} + + + maven-enforcer-plugin + 1.4.1 + + + enforce + + + + + + + enforce + + + + + + + maven-compiler-plugin + 3.1 + + 1.8 + 1.8 + + -Xlint:all + + + + + + maven-dependency-plugin + 2.10 + + + prepare-package + + copy-dependencies + + + + + false + false + true + runtime + ${project.build.directory}/lib + + + + + maven-jar-plugin + 2.6 + + + true + + true + true + true + lib/ + com.spotify.apollo.example.SpotifyApiExample + + + + + + + diff --git a/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/AlbumResource.java b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/AlbumResource.java new file mode 100644 index 000000000..b06990745 --- /dev/null +++ b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/AlbumResource.java @@ -0,0 +1,109 @@ +package com.spotify.apollo.example; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.spotify.apollo.Client; +import com.spotify.apollo.Request; +import com.spotify.apollo.RequestContext; +import com.spotify.apollo.Response; +import com.spotify.apollo.Status; +import com.spotify.apollo.example.data.Album; +import com.spotify.apollo.example.data.Artist; +import com.spotify.apollo.route.AsyncHandler; +import com.spotify.apollo.route.Middlewares; +import com.spotify.apollo.route.Route; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.StringJoiner; +import java.util.concurrent.CompletionStage; +import java.util.stream.Stream; + +import okio.ByteString; + +/** + * The album resource demonstrates how to use asynchronous routes in Apollo. + */ +public class AlbumResource { + + private static final String SPOTIFY_API = "https://api.spotify.com"; + private static final String SEARCH_API = SPOTIFY_API + "/v1/search"; + private static final String ALBUM_API = SPOTIFY_API + "/v1/albums"; + + public Stream>>> routes() { + // The album resource has two routes. Both are using the autoserializer. + return Stream.of( + Route.async("GET", "/albums/new", this::getAlbums), + Route.async("GET", "/albums/hipster", this::getAlbums) + ) + .map(route -> route.withMiddleware(Middlewares::autoSerialize)); + } + + public CompletionStage>> getAlbums(RequestContext requestContext) { + String uri = requestContext.request().uri(); + String tag = uri.substring(uri.lastIndexOf("/") + 1); + + // We need to first query the search API, parse the result, then query the album API. + Request searchRequest = Request.forUri(SEARCH_API + "?type=album&q=tag%3A" + tag); + Client client = requestContext.requestScopedClient(); + return client + .send(searchRequest) + .thenComposeAsync(response -> { + String ids = parseResponseAlbumIds(response.payload().get().utf8()); + return client.send(Request.forUri(ALBUM_API + "?ids=" + ids)); + }) + .thenApplyAsync(response -> Response.ok() + .withPayload(parseAlbumData(response.payload().get().utf8()))) + .exceptionally(throwable -> Response + .forStatus(Status.INTERNAL_SERVER_ERROR.withReasonPhrase("Something went wrong"))); + } + + /** + * Parses an album response from a + * Spotify API album query. + * + * @param json The json response + * @return A list of albums with artist information + */ + private ArrayList parseAlbumData(String json) { + ArrayList albums = new ArrayList<>(); + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(json); + for (JsonNode albumNode : jsonNode.get("albums")) { + JsonNode artistsNode = albumNode.get("artists"); + // Exclude albums with 0 artists + if (artistsNode.size() >= 1) { + // Only keeping the first artist for simplicity + Artist artist = new Artist(artistsNode.get(0).get("name").asText()); + Album album = new Album(albumNode.get("name").asText(), artist); + albums.add(album); + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to parse JSON", e); + } + return albums; + } + + /** + * Parses the album ids from a JSON response from a + * Spotify API search query. + * + * @param json The JSON response + * @return A comma-separated list of album ids from the response + */ + private String parseResponseAlbumIds(String json) { + StringJoiner sj = new StringJoiner(","); + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(json); + for (JsonNode node : jsonNode.get("albums").get("items")) { + sj.add(node.get("id").asText()); + } + } catch (IOException e) { + throw new RuntimeException("Failed to parse JSON", e); + } + return sj.toString(); + } +} diff --git a/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/ArtistResource.java b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/ArtistResource.java new file mode 100644 index 000000000..7972598a5 --- /dev/null +++ b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/ArtistResource.java @@ -0,0 +1,113 @@ +package com.spotify.apollo.example; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.spotify.apollo.Client; +import com.spotify.apollo.Request; +import com.spotify.apollo.RequestContext; +import com.spotify.apollo.Response; +import com.spotify.apollo.Status; +import com.spotify.apollo.example.data.Album; +import com.spotify.apollo.example.data.Artist; +import com.spotify.apollo.example.data.Track; +import com.spotify.apollo.route.AsyncHandler; +import com.spotify.apollo.route.Middlewares; +import com.spotify.apollo.route.Route; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.stream.Stream; + +import okio.ByteString; + +/** + * The artist resource demonstrates how to use asynchronous routes in Apollo with query arguments. + */ +public class ArtistResource { + + private static final String SPOTIFY_API = "https://api.spotify.com"; + private static final String SEARCH_API = SPOTIFY_API + "/v1/search"; + private static final String ARTIST_API = SPOTIFY_API + "/v1/artists"; + + public Stream>>> routes() { + // The artist resource has one parameterized route. + return Stream.of( + Route.async("GET", "/artists/toptracks/", this::getArtistTopTracks) + .withMiddleware(Middlewares::autoSerialize) + ); + } + + public CompletionStage>> getArtistTopTracks(RequestContext requestContext) { + // Validate request + Optional query = requestContext.request().parameter("q"); + if (!query.isPresent()) { + return CompletableFuture.completedFuture( + Response.forStatus(Status.BAD_REQUEST.withReasonPhrase("No search query"))); + } + String country = requestContext.pathArgs().get("country"); + + // We need to first query the search API, parse the result, then query top-tracks. + Request searchRequest = Request.forUri(SEARCH_API + "?type=artist&q=" + query.get()); + Client client = requestContext.requestScopedClient(); + return client + .send(searchRequest) + .thenComposeAsync(response -> { + String topArtistId = parseFirstArtistId(response.payload().get().utf8()); + Request topTracksRequest = Request.forUri( + String.format("%s/%s/top-tracks?country=%s", ARTIST_API, topArtistId, country)); + return client.send(topTracksRequest); + }).thenApplyAsync(response -> Response.ok() + .withPayload(parseTopTracks(response.payload().get().utf8()))) + .exceptionally(throwable -> Response + .forStatus(Status.INTERNAL_SERVER_ERROR.withReasonPhrase("Something went wrong"))); + } + + /** + * Parses an artist top tracks response from the + * Spotify API + * + * @param json The json response + * @return A list of top tracks + */ + private ArrayList parseTopTracks(String json) { + ArrayList tracks = new ArrayList<>(); + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(json); + for (JsonNode trackNode : jsonNode.get("tracks")) { + JsonNode albumNode = trackNode.get("album"); + String albumName = albumNode.get("name").asText(); + String artistName = trackNode.get("artists").get(0).get("name").asText(); + String trackName = trackNode.get("name").asText(); + + tracks.add(new Track(trackName, new Album(albumName, new Artist(artistName)))); + } + } catch (IOException e) { + throw new RuntimeException("Failed to parse JSON", e); + } + return tracks; + } + + /** + * Parses the first artist id from a JSON response from a + * Spotify API search query. + * + * @param json The json response + * @return The id of the first artist in the response. null if response was empty. + */ + private String parseFirstArtistId(String json) { + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(json); + for (JsonNode node : jsonNode.get("artists").get("items")) { + return node.get("id").asText(); + } + } catch (IOException e) { + throw new RuntimeException("Failed to parse JSON", e); + } + return null; + } +} diff --git a/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/SpotifyApiExample.java b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/SpotifyApiExample.java new file mode 100644 index 000000000..3d931e819 --- /dev/null +++ b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/SpotifyApiExample.java @@ -0,0 +1,46 @@ +package com.spotify.apollo.example; + +import com.spotify.apollo.Environment; +import com.spotify.apollo.RequestContext; +import com.spotify.apollo.Response; +import com.spotify.apollo.httpservice.HttpService; +import com.spotify.apollo.httpservice.LoadingException; +import com.spotify.apollo.route.AsyncHandler; +import com.spotify.apollo.route.Route; + +import java.util.function.Function; +import java.util.stream.Stream; + +import okio.ByteString; + +/** + * This example demonstrates how to setup multiple routes in Apollo. + * + * It uses asynchronous routes that calls the Spotify API to fetch some data and then process it. + */ +final class SpotifyApiExample { + + public static void main(String[] args) throws LoadingException { + HttpService.boot(SpotifyApiExample::init, "spotify-api-example-service", args); + } + + static void init(Environment environment) { + AlbumResource albumResource = new AlbumResource(); + ArtistResource artistResource = new ArtistResource(); + + Stream>>> routes = + Stream.of( + albumResource.routes(), + artistResource.routes(), + Stream.of( + Route.sync("GET", "/ping", SpotifyApiExample::ping) + ) + ).flatMap(Function.identity()); + + environment.routingEngine().registerRoutes(routes); + } + + public static Response ping(RequestContext requestContext) { + return Response.ok().withPayload(ByteString.encodeUtf8("pong!")); + } +} diff --git a/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/data/Album.java b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/data/Album.java new file mode 100644 index 000000000..924c5a4e6 --- /dev/null +++ b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/data/Album.java @@ -0,0 +1,19 @@ +package com.spotify.apollo.example.data; + +public class Album { + private String name; + private Artist artist; + + public String getName() { + return name; + } + + public Artist getArtist() { + return artist; + } + + public Album(String name, Artist artist) { + this.name = name; + this.artist = artist; + } +} diff --git a/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/data/Artist.java b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/data/Artist.java new file mode 100644 index 000000000..db476b00d --- /dev/null +++ b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/data/Artist.java @@ -0,0 +1,13 @@ +package com.spotify.apollo.example.data; + +public class Artist { + private String name; + + public String getName() { + return name; + } + + public Artist(String name) { + this.name = name; + } +} diff --git a/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/data/Track.java b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/data/Track.java new file mode 100644 index 000000000..33f52eb77 --- /dev/null +++ b/examples/spotify-api-example/src/main/java/com/spotify/apollo/example/data/Track.java @@ -0,0 +1,19 @@ +package com.spotify.apollo.example.data; + +public class Track { + private String name; + private Album album; + + public String getName() { + return name; + } + + public Album getAlbum() { + return album; + } + + public Track(String name, Album album) { + this.name = name; + this.album = album; + } +} diff --git a/examples/spotify-api-example/src/main/resources/logback.xml b/examples/spotify-api-example/src/main/resources/logback.xml new file mode 100644 index 000000000..1897ef41a --- /dev/null +++ b/examples/spotify-api-example/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/examples/spotify-api-example/src/main/resources/spotify-api-example-service.conf b/examples/spotify-api-example/src/main/resources/spotify-api-example-service.conf new file mode 100644 index 000000000..067040ed3 --- /dev/null +++ b/examples/spotify-api-example/src/main/resources/spotify-api-example-service.conf @@ -0,0 +1,2 @@ +apollo.domain = example +http.server.port = 8080