001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2018 Richard "Shred" K├Ârber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j;
015
016import java.net.URL;
017import java.time.Instant;
018import java.util.Objects;
019import java.util.Optional;
020
021import edu.umd.cs.findbugs.annotations.Nullable;
022import org.shredzone.acme4j.exception.AcmeException;
023import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
024import org.shredzone.acme4j.exception.AcmeRetryAfterException;
025import org.shredzone.acme4j.toolbox.JSON;
026import org.slf4j.Logger;
027import org.slf4j.LoggerFactory;
028
029/**
030 * An extension of {@link AcmeResource} that also contains the current state of a resource
031 * as JSON document. If the current state is not present, this class takes care of
032 * fetching it from the server if necessary.
033 */
034public abstract class AcmeJsonResource extends AcmeResource {
035    private static final long serialVersionUID = -5060364275766082345L;
036    private static final Logger LOG = LoggerFactory.getLogger(AcmeJsonResource.class);
037
038    private @Nullable JSON data = null;
039    private @Nullable Instant retryAfter = null;
040
041    /**
042     * Create a new {@link AcmeJsonResource}.
043     *
044     * @param login
045     *            {@link Login} the resource is bound with
046     * @param location
047     *            Location {@link URL} of this resource
048     */
049    protected AcmeJsonResource(Login login, URL location) {
050        super(login, location);
051    }
052
053    /**
054     * Returns the JSON representation of the resource data.
055     * <p>
056     * If there is no data, {@link #update()} is invoked to fetch it from the server.
057     * <p>
058     * This method can be used to read proprietary data from the resources.
059     *
060     * @return Resource data, as {@link JSON}.
061     * @throws AcmeLazyLoadingException
062     *         if an {@link AcmeException} occured while fetching the current state from
063     *         the server.
064     */
065    public JSON getJSON() {
066        if (data == null) {
067            try {
068                fetch();
069            } catch (AcmeException ex) {
070                throw new AcmeLazyLoadingException(this, ex);
071            }
072        }
073        return Objects.requireNonNull(data);
074    }
075
076    /**
077     * Sets the JSON representation of the resource data.
078     *
079     * @param data
080     *            New {@link JSON} data, must not be {@code null}.
081     */
082    protected void setJSON(JSON data) {
083        invalidate();
084        this.data = Objects.requireNonNull(data, "data");
085    }
086
087    /**
088     * Checks if this resource is valid.
089     *
090     * @return {@code true} if the resource state has been loaded from the server. If
091     *         {@code false}, {@link #getJSON()} would implicitly call {@link #fetch()}
092     *         to fetch the current state from the server.
093     */
094    protected boolean isValid() {
095        return data != null;
096    }
097
098    /**
099     * Invalidates the state of this resource. Enforces a {@link #fetch()} when
100     * {@link #getJSON()} is invoked.
101     * <p>
102     * Subclasses can override this method to purge internal caches that are based on the
103     * JSON structure. Remember to invoke {@code super.invalidate()}!
104     */
105    protected void invalidate() {
106        data = null;
107        retryAfter = null;
108    }
109
110    /**
111     * Updates this resource, by fetching the current resource data from the server.
112     * <p>
113     * Note: Prefer to use {@link #fetch()} instead. It is working the same way, but
114     * returns the Retry-After instant instead of throwing an exception. This method will
115     * become deprecated in a future release.
116     *
117     * @throws AcmeException
118     *         if the resource could not be fetched.
119     * @throws AcmeRetryAfterException
120     *         the resource is still being processed, and the server returned an estimated
121     *         date when the process will be completed. If you are polling for the
122     *         resource to complete, you should wait for the date given in
123     *         {@link AcmeRetryAfterException#getRetryAfter()}. Note that the status of
124     *         the resource is updated even if this exception was thrown.
125     * @see #fetch()
126     */
127    public void update() throws AcmeException {
128        var retryAfter = fetch();
129        if (retryAfter.isPresent()) {
130            throw new AcmeRetryAfterException(getClass().getSimpleName() + " is not completed yet", retryAfter.get());
131        }
132    }
133
134    /**
135     * Updates this resource, by fetching the current resource data from the server.
136     *
137     * @return An {@link Optional} estimation when the resource status will change. If you
138     * are polling for the resource to complete, you should wait for the given instant
139     * before trying again. Empty if the server did not return a "Retry-After" header.
140     * @throws AcmeException
141     *         if the resource could not be fetched.
142     * @see #update()
143     * @since 3.2.0
144     */
145    public Optional<Instant> fetch() throws AcmeException {
146        var resourceType = getClass().getSimpleName();
147        LOG.debug("update {}", resourceType);
148        try (var conn = getSession().connect()) {
149            conn.sendSignedPostAsGetRequest(getLocation(), getLogin());
150            setJSON(conn.readJsonResponse());
151            var retryAfterOpt = conn.getRetryAfter();
152            retryAfterOpt.ifPresent(instant -> LOG.debug("Retry-After: {}", instant));
153            setRetryAfter(retryAfterOpt.orElse(null));
154            return retryAfterOpt;
155        } catch (AcmeRetryAfterException ex) {
156            LOG.debug("Retry-After while attempting to read the resource", ex);
157            setRetryAfter(ex.getRetryAfter());
158            return Optional.of(ex.getRetryAfter());
159        }
160    }
161
162    /**
163     * Sets a Retry-After instant.
164     *
165     * @since 3.2.0
166     */
167    protected void setRetryAfter(@Nullable Instant retryAfter) {
168        this.retryAfter = retryAfter;
169    }
170
171    /**
172     * Gets an estimation when the resource status will change. If you are polling for
173     * the resource to complete, you should wait for the given instant before trying
174     * a status refresh.
175     * <p>
176     * This instant was sent with the Retry-After header at the last update.
177     *
178     * @return Retry-after {@link Instant}, or empty if there was no such header.
179     * @since 3.2.0
180     */
181    public Optional<Instant> getRetryAfter() {
182        return Optional.ofNullable(retryAfter);
183    }
184
185}