Netlify.java
/*-
* #%L
* io.earcam.utilitarian.site.deploy.netlify
* %%
* Copyright (C) 2017 earcam
* %%
* SPDX-License-Identifier: (BSD-3-Clause OR EPL-1.0 OR Apache-2.0 OR MIT)
*
* You <b>must</b> choose to accept, in full - any individual or combination of
* the following licenses:
* <ul>
* <li><a href="https://opensource.org/licenses/BSD-3-Clause">BSD-3-Clause</a></li>
* <li><a href="https://www.eclipse.org/legal/epl-v10.html">EPL-1.0</a></li>
* <li><a href="https://www.apache.org/licenses/LICENSE-2.0">Apache-2.0</a></li>
* <li><a href="https://opensource.org/licenses/MIT">MIT</a></li>
* </ul>
* #L%
*/
package io.earcam.utilitarian.site.deploy.netlify;
import static io.earcam.unexceptional.Closing.closeAfterAccepting;
import static java.time.Instant.MIN;
import static java.util.Collections.singletonMap;
import static javax.ws.rs.client.ClientBuilder.newBuilder;
import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE;
import static javax.ws.rs.core.Response.Status.Family.SUCCESSFUL;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.annotation.WillClose;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.json.JsonValue;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.StreamingOutput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.earcam.unexceptional.CheckedConsumer;
import io.earcam.unexceptional.EmeticStream;
import io.earcam.utilitarian.web.jaxrs.JsonMessageBodyReader;
import io.earcam.utilitarian.web.jaxrs.JsonMessageBodyWriter;
import io.earcam.utilitarian.web.jaxrs.TokenBearerAuthenticator;
import io.earcam.utilitarian.web.jaxrs.UserAgent;
/**
* API client for <a href="https://netlify.com">Netlify</a>
*/
public class Netlify {
private static final String USER_AGENT = "Mozilla/5.0 (X11; YouNix; Linux x86_64; rv:53.0) earcam.io/1.0";
private static final MediaType APPLICATION_ZIP_TYPE = new MediaType("application", "zip");
public static final String BASE_URL = "https://api.netlify.com/api/v1/";
private static final Logger LOG = LoggerFactory.getLogger(Netlify.class);
private Client client;
private String baseUrl;
public Netlify(String accessToken)
{
this(accessToken, newBuilder().build(), BASE_URL);
}
public Netlify(String accessToken, String baseUrl)
{
this(accessToken, newBuilder().build(), baseUrl);
}
public Netlify(String accessToken, Client client, String baseUrl)
{
this.client = configure(accessToken, client);
this.baseUrl = ensureTrailingSlash(baseUrl);
}
private static Client configure(String accessToken, Client client)
{
return client.register(new TokenBearerAuthenticator(accessToken))
.register(new UserAgent(USER_AGENT))
.register(new JsonMessageBodyReader())
.register(new JsonMessageBodyWriter());
}
private String ensureTrailingSlash(String earl)
{
return earl.charAt(earl.length() - 1) == '/' ? earl : earl + '/';
}
public Site create(Site site)
{
StreamingOutput o = site::writeJson;
Response response = client.target(baseUrl + "sites")
.request(APPLICATION_JSON_TYPE)
.post(Entity.entity(o, APPLICATION_JSON_TYPE));
checkSuccessful(response);
JsonObject json = response.readEntity(JsonObject.class);
return Site.fromJsonObject(json);
}
static void checkSuccessful(Response response)
{
if(response.getStatusInfo().getFamily() != SUCCESSFUL) { // should be a ???
throw requestFailedException(response);
}
}
static IllegalStateException requestFailedException(Response response)
{
return new IllegalStateException(response.getStatus() + " - " +
response.getStatusInfo().getReasonPhrase());
}
public List<Site> list()
{
throw new UnsupportedOperationException("TODO");
}
public void destroy(String siteName)
{
Site site = siteForName(siteName);
Response response = client.target(baseUrl + "sites/" + site.id())
.request()
.delete();
checkSuccessful(response);
}
private Site siteForName(String siteName)
{
return findSiteForName(siteName).orElseThrow(() -> new RuntimeException("No site found with name: " + siteName));
}
public void deployZip(String siteName, String uploadPath, Path baseDir)
{
deployZip(siteName, singletonMap(uploadPath, baseDir));
}
public void deployZip(String siteName, Map<String, Path> baseDirs)
{
Site site = siteForName(siteName);
deploySiteZip(baseDirs, site);
}
protected void deploySiteZip(Map<String, Path> baseDirs, Site site)
{
StreamingOutput body = o -> writeZip(baseDirs, o);
Response response = client.target(baseUrl + "sites/" + site.id() + "/deploys")
.request(APPLICATION_JSON_TYPE)
.post(Entity.entity(body, APPLICATION_ZIP_TYPE));
LOG.debug("response: {}", response);
checkSuccessful(response);
JsonObject json = response.readEntity(JsonObject.class);
String state = json.getString("state", "unknown");
if(!"uploaded".equals(state)) {
throw new IllegalStateException("Response JSON doesn't include 'state=\"uploaded\"', " + state + ". JSON: " + json);
}
}
protected void writeZip(Map<String, Path> baseDirs, @WillClose OutputStream output)
{
closeAfterAccepting(ZipOutputStream::new, output, baseDirs, this::doWriteZip);
}
private void doWriteZip(ZipOutputStream zip, Map<String, Path> baseDirs)
{
@SuppressWarnings("squid:S1905") // false positive; cast IS required
CheckedConsumer<byte[], IOException> writeThenClose = ((CheckedConsumer<byte[], IOException>) zip::write).andThen(b -> zip.closeEntry());
for(Entry<String, Path> e : baseDirs.entrySet()) {
EmeticStream.emesis(Files::walk, e.getValue())
.sequential()
.sorted(Path::compareTo)
.filter(Files::isRegularFile)
// Quick hack, TODO add excludes, but sitemap should also clean up it's cache files
.filter(p -> !p.getFileName().toString().startsWith(".io.earcam.utilitarian.site.sitemap."))
.peek(f -> LOG.debug("Writing to zip: {}", f))
.peek(f -> zipEntry(zip, e, f))
.map(Files::readAllBytes)
.forEach(writeThenClose);
}
}
/*
* Timestamps set to constant values purely to make wire-mock testing easy (irrelevant for Netlify)
*/
private void zipEntry(ZipOutputStream zip, Entry<String, Path> baseDir, Path file) throws IOException
{
URI relativePath = baseDir.getValue().toUri().relativize(file.toUri());
String absolutePath = baseDir.getKey() + relativePath;
ZipEntry entry = new ZipEntry(absolutePath);
LOG.debug("Absolute path in site: /{}", absolutePath);
entry.setTime(0);
entry.setCreationTime(FileTime.from(MIN));
entry.setLastAccessTime(FileTime.from(MIN));
entry.setLastModifiedTime(FileTime.from(MIN));
zip.putNextEntry(entry);
}
public Optional<Site> findSiteForName(String siteName)
{
List<Site> json = siteList();
return json.stream()
.filter(s -> siteName.equals(s.name()))
.findAny();
}
List<Site> siteList()
{
return client.target(baseUrl + "sites")
.request(APPLICATION_JSON_TYPE)
.get()
.readEntity(JsonArray.class)
.stream()
.map(JsonValue::asJsonObject)
.map(Site::fromJsonObject)
.collect(Collectors.toList());
}
}