Problem.java

package de.turnertech.problemdetails;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.InvalidParameterException;
import java.util.Locale;
import java.util.Objects;
import java.util.ResourceBundle;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;

/**
 * A POJO representation of the urn:ietf:rfc:7807 problem type.
 */
public class Problem {
    
    /** urn:ietf:rfc:7807 */
    public static final String NAMESPACE = "urn:ietf:rfc:7807";
    
    /** application/problem+xml */
    public static final String MEDIA_TYPE_XML = "application/problem+xml";
    
    /** application/problem+json */
    public static final String MEDIA_TYPE_JSON = "application/problem+json";
    
    private static final ResourceBundle i18n = ResourceBundle.getBundle("de.turnertech.problemdetails.i18n");

    private static final String PROBLEM_STRING = "problem";
    
    // Mandatory with default
    private URI type;
    
    // Optional, must be same as HTTP response if present
    private Integer status;
    
    // Advisory
    private String title;
    
    // Optional
    private String detail;
    
    // Optional
    private URI instance;

    /**
     * Constructs an empty Problem with the type "about:blank" (RFC 9457 - 4.2.1.).
     */
    public Problem() {
        this(URI.create("about:blank"));
    }

    /**
     * Constructs a Problem with the provided parameters. Note that type may not be null.
     * @param type RFC 9457 - 3.1.1.
     * @throws NullPointerException if type is null.
     */
    public Problem(URI type) {
        this(type, null);
    }

    /**
     * Constructs a Problem with the provided parameters. Note that type may not be null.
     * @param type RFC 9457 - 3.1.1.
     * @param instance RFC 9457 - 3.1.5.
     * @throws NullPointerException if type is null.
     */
    public Problem(URI type, URI instance) throws NullPointerException {
        this.type = Objects.requireNonNull(type, i18n.getString("error.type.nonnull"));
        this.instance = instance;
    }
    
    /**
     * Constructs a deep copy of the provided problem. If you extend this class, make sure
     * that your implementation of the copy constructor also returns deep copies.
     * @param other problem to copy.
     */
    public Problem(Problem other) {
        this.type = other.type;
        this.status = other.status;
        this.title = other.title;
        this.detail = other.detail;
        this.instance = other.instance;
    }

    /**
     * Gets the problem type.
     * @return the problem type.
     */
    public URI getType() {
        return type;
    }

    /**
     * Sets the problem type.
     * @param type the problem type.
     * @throws NullPointerException if type is null
     */
    public void setType(URI type) throws NullPointerException {
        this.type = Objects.requireNonNull(type, i18n.getString("error.type.nonnull"));
    }

    /**
     * Gets the problem status code (HTTP Status Code).
     * @return the problem status code (HTTP Status Code)
     */
    public Integer getStatus() {
        return status;
    }

    /**
     * Sets the problem status code (HTTP Status Code). If set, this must be the same as the one used in your HTTP Response.
     * @param status the problem status code (HTTP Status Code).
     * @see #findStatusPhrase(int)
     */
    public void setStatus(Integer status) {
        if(status != null && (status < 100 || status > 599)) {
            throw new InvalidParameterException(i18n.getString("error.status.inrange"));
        }
        this.status = status;
    }

    /**
     * Gets the problem title.
     * @return the problem title.
     */
    public String getTitle() {
        return title;
    }

    /**
     * Sets the problem title.
     * @param title the short human readable title.
     * @see #findStatusPhrase(int)
     */
    public void setTitle(String title) {
        this.title = title;
    }

    /**
     * Gets the problem detail.
     * @return the problem detail.
     */
    public String getDetail() {
        return detail;
    }

    /**
     * Sets the problem detail.
     * @param detail the problem detail.
     */
    public void setDetail(String detail) {
        this.detail = detail;
    }

    /**
     * Gets the problem instance.
     * @return the problem instance.
     */
    public URI getInstance() {
        return instance;
    }

    /**
     * Sets the problem instance.
     * @param instance the problem instance.
     */
    public void setInstance(URI instance) {
        this.instance = instance;
    }

    /**
     * Helper for retrieving the HTTP Status Phrase for a HTTP Status Code.
     * @param statusCode the HTTP Status Code.
     * @return the HTTP Status Phrase in English, may be null.
     * @see #findStatusPhrase(int, Locale)
     */
    public static String findStatusPhrase(int statusCode) {
        return findStatusPhrase(statusCode, Locale.ENGLISH);
    }

    /**
     * Helper for retrieving the localised HTTP Status Phrase for a HTTP Status Code.
     * @param statusCode the HTTP Status Code.
     * @param locale the desired language.
     * @return the HTTP Status Phrase, may be null.
     * @see #findStatusPhrase(int)
     */
    public static String findStatusPhrase(int statusCode, Locale locale) {
        ResourceBundle strings = ResourceBundle.getBundle("de.turnertech.problemdetails.i18n", locale);
        return strings.getString(Integer.toString(statusCode));
    }
    
    /**
     * Converts the contents to a String containing the JSON representation of this Problem.
     * @return the JSON String.
     * @throws IOException if there are problems with extending the JSON.
     * @see #extendJson(OutputStream, Charset)
     * @see #toJson(OutputStream)
     * @see #toJson(OutputStream, Charset)
     */
    public String toJson() throws IOException {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            toJson(outputStream);
            return outputStream.toString(StandardCharsets.UTF_8);
        }
    }

    /**
     * Writes the Problem to the supplied stream in JSON format using UTF-8.
     * @param outputStream the stream to write to.
     * @throws IOException if there are problems with extending the JSON.
     * @see #extendJson(OutputStream, Charset)
     * @see #toJson()
     * @see #toJson(OutputStream, Charset)
     */
    public void toJson(OutputStream outputStream) throws IOException {
        toJson(outputStream, StandardCharsets.UTF_8);
    }

    /**
     * Writes the Problem to the supplied stream in JSON format using the supplied Charset.
     * @param outputStream the stream to write to.
     * @param charset the Charset to use.
     * @throws IOException if there are problems with extending the JSON.
     */
    public void toJson(OutputStream outputStream, Charset charset) throws IOException {
        try(PrintWriter printWriter = new PrintWriter(outputStream)) {
            printWriter.write("{\"type\":\"");
            printWriter.write(type.toString());
            printWriter.write("\"");

            if(title != null) {
                printWriter.write(",\"title\":\"");
                printWriter.write(title);
                printWriter.write("\"");
            }
            if(status != null) {
                printWriter.write(",\"status\":\"");
                printWriter.write(Integer.toString(status));
                printWriter.write("\"");
            }
            if(detail != null) {
                printWriter.write(",\"detail\":\"");
                printWriter.write(detail);
                printWriter.write("\"");
            }
            if(instance != null) {
                printWriter.write(",\"instance\":\"");
                printWriter.write(instance.toString());
                printWriter.write("\"");
            }

            printWriter.flush();

        }

        extendJson(outputStream, charset);

        outputStream.write("}".getBytes(charset));
    }

    /**
     * <p>Override this if you wish to extend the JSON response. This function is called directly before
     * closing the problem element. Pay carefull attention to the namespaces!</p>
     * @param outputStream the stream to write to
     * @param charset the charset this XML is being written in
     * @return true if an element was inserted (so that the caller can decide whether or not to write a ",")
     */
    protected boolean extendJson(OutputStream outputStream, Charset charset) {
        // Called before closing the last element.
        return false;
    }

    /**
     * Converts the contents to a String containing the XML representation of this Problem.
     * @return the XML String.
     * @throws XMLStreamException if there are problems with extending the XML.
     * @see #extendXml(XMLStreamWriter, Charset)
     * @see #toXml(OutputStream, Charset, boolean)
     * @see #toXml(XMLStreamWriter, Charset, boolean)
     */
    public String toXml() throws XMLStreamException {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            toXml(outputStream, StandardCharsets.UTF_8, true);
            return outputStream.toString(StandardCharsets.UTF_8);
        } catch (IOException e) {
            return null;
        }
    }
    
    /**
     * Writes the Problem to the supplied stream in XML format using the supplied charset.
     * @param outputStream to write to.
     * @param charset to write using.
     * @param writeStartDocument to indicate if the xml start document should also be written.
     * @throws XMLStreamException if there are problems with extending the XML.
     */
    public void toXml(OutputStream outputStream, Charset charset, boolean writeStartDocument) throws XMLStreamException {
        XMLOutputFactory outputFactory = XMLOutputFactory.newFactory();
        outputFactory.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, true);
        XMLStreamWriter xmlStreamWriter = outputFactory.createXMLStreamWriter(outputStream, charset.toString());
        toXml(xmlStreamWriter, charset, writeStartDocument);
    }
    
    /**
     * Writes the Problem to the supplied writer in XML format using the supplied charset.
     * @param xmlStreamWriter to write using.
     * @param charset to write using.
     * @param writeStartDocument to indicate if the xml start document should also be written.
     * @throws XMLStreamException if there are problems with extending the XML.
     */
    public void toXml(XMLStreamWriter xmlStreamWriter, Charset charset, boolean writeStartDocument) throws XMLStreamException {
        if(writeStartDocument) {
            xmlStreamWriter.writeStartDocument(charset.toString(), "1.0");
        }
        
        String defaultNs = xmlStreamWriter.getNamespaceContext().getNamespaceURI("");
        String prefix = xmlStreamWriter.getNamespaceContext().getPrefix(NAMESPACE);
        
        if(prefix != null) {
            xmlStreamWriter.writeStartElement(NAMESPACE, PROBLEM_STRING);
        } else if(defaultNs == null) {
            xmlStreamWriter.writeStartElement(PROBLEM_STRING);
            xmlStreamWriter.writeDefaultNamespace(NAMESPACE);
        } else {
            prefix = "p";
            int i = 0;
            while(xmlStreamWriter.getNamespaceContext().getNamespaceURI(prefix) != null) {
                prefix = "p" + Integer.toString(i);
            }
            xmlStreamWriter.writeStartElement(prefix, PROBLEM_STRING, NAMESPACE);
            xmlStreamWriter.writeNamespace(prefix, NAMESPACE);
        }
        
        if(type != null) {
            xmlStreamWriter.writeStartElement(NAMESPACE, PROBLEM_STRING);
            xmlStreamWriter.writeCharacters(type.toString());
            xmlStreamWriter.writeEndElement();
        }

        if(title != null) {
            xmlStreamWriter.writeStartElement(NAMESPACE, "title");
            xmlStreamWriter.writeCharacters(title);
            xmlStreamWriter.writeEndElement();
        }

        if(status != null) {
            xmlStreamWriter.writeStartElement(NAMESPACE, "status");
            xmlStreamWriter.writeCharacters(Integer.toString(status));
            xmlStreamWriter.writeEndElement();
        }

        if(detail != null) {
            xmlStreamWriter.writeStartElement(NAMESPACE, "detail");
            xmlStreamWriter.writeCharacters(detail);
            xmlStreamWriter.writeEndElement();
        }

        if(instance != null) {
            xmlStreamWriter.writeStartElement(NAMESPACE, "instance");
            xmlStreamWriter.writeCharacters(instance.toString());
            xmlStreamWriter.writeEndElement();
        }
        
        extendXml(xmlStreamWriter, charset);
        
        // /problem
        xmlStreamWriter.writeEndElement();
        
        if(writeStartDocument) {
            xmlStreamWriter.writeEndDocument();
        }
    }
    
    /**
     * Override this if you wish to extend the XML response. This function is called directly before
     * closing the problem element. Pay carefull attention to the namespaces!
     * @param xmlStreamWriter the writer you must use to write to the stream
     * @param charset the charset this XML is being written in
     * @throws XMLStreamException if there are problems with extending the XML.
     */
    protected void extendXml(XMLStreamWriter xmlStreamWriter, Charset charset) throws XMLStreamException {
        // Called before closing the last element.
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((type == null) ? 0 : type.hashCode());
        result = prime * result + ((status == null) ? 0 : status.hashCode());
        result = prime * result + ((title == null) ? 0 : title.hashCode());
        result = prime * result + ((detail == null) ? 0 : detail.hashCode());
        result = prime * result + ((instance == null) ? 0 : instance.hashCode());
        return result;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        Problem other = (Problem) obj;
        if (type == null) {
            if (other.type != null)
                return false;
        } else if (!type.equals(other.type))
            return false;
        if (status == null) {
            if (other.status != null)
                return false;
        } else if (!status.equals(other.status))
            return false;
        if (title == null) {
            if (other.title != null)
                return false;
        } else if (!title.equals(other.title))
            return false;
        if (detail == null) {
            if (other.detail != null)
                return false;
        } else if (!detail.equals(other.detail))
            return false;
        if (instance == null) {
            if (other.instance != null)
                return false;
        } else if (!instance.equals(other.instance))
            return false;
        return true;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {
        return "Problem [type=" + type + ", title=" + title + "]";
    }
    
}