Implement RFC 7807 "Problem Details for HTTP APIs"
authorPhilippe Pepiot <philippe.pepiot@logilab.fr>
Tue, 04 Jul 2017 15:54:23 +0200
changeset 249 e641480b7e11
parent 248 3d18fb4a7444
child 250 e7fa3b86f6c4
Implement RFC 7807 "Problem Details for HTTP APIs" cubicweb-jsonschema recently moved from json-api errors style to RFC 7807 json problem. On ResourceEditionForm when response content type is 'application/problem+json', display errors message. TODO: Handle and display errors globally, any request to the API can return a json problem. TODO: When 'invalid-params' has some pointer to a particular field being edited, display error next to the field. Note that 'invalid-params' is a local specification and is not part of RFC 7807.
src/components/Entity.js
src/components/Resource.js
src/services/hypermedia.js
src/utils.js
src/utils.spec.js
--- a/src/components/Entity.js	Tue Jul 04 12:08:35 2017 +0200
+++ b/src/components/Entity.js	Tue Jul 04 15:54:23 2017 +0200
@@ -117,35 +117,19 @@
 
     constructor(props, context) {
         super(props, context);
-        this.state = {schema: props.schema, formData: props.formData, uiSchema: uiSchema, _errors: null};
+        this.state = {schema: props.schema, formData: props.formData, uiSchema: uiSchema};
     }
 
     render() {
         if (!this.state.schema) {
             return <div>loading...</div>;
         }
-        const renderValidationError = error => {
-            let attrname;
-            if (error.hasOwnProperty('source')) {
-                attrname = error.source.pointer;
-            }
-            return [attrname, error.details || error.title].join(': ');
-        };
-        let errors = this.state._errors || this.props.errors;
-        if (errors) {
-            errors = errors.map(
-                error => (
-                    <div className="alert alert-danger">
-                        { renderValidationError(error) }
-                    </div>
-                )
-            );
-        }
         // Can be implemented in derived class.
         const onSubmit = this.props.onSubmit || this.onSubmit.bind(this);
         return (
             <div>
-                {errors}
+                {this.props.errors ? this.props.errors.map((error, i) => (
+                    <div key={i} className="alert alert-danger">{error}</div>)) : null}
                 <FormWrapper {...this.state} onSubmit={onSubmit} />
             </div>
         );
@@ -155,7 +139,7 @@
 EntityForm.propTypes = {
     onSubmit: PropTypes.func,
     schema: PropTypes.object,
-    errors: PropTypes.object,
+    errors: PropTypes.array,
     formData: PropTypes.any,
 };
 
--- a/src/components/Resource.js	Tue Jul 04 12:08:35 2017 +0200
+++ b/src/components/Resource.js	Tue Jul 04 15:54:23 2017 +0200
@@ -4,7 +4,7 @@
 import {merge} from 'lodash/object';
 import {Link} from 'react-router-dom';
 
-import {buildFormData} from '../utils';
+import {buildFormData, getErrors} from '../utils';
 import {EntityForm} from './Entity';
 import {PropTypesResourceModel} from '../model';
 import {mapToSchema} from '../jsonaryutils';
@@ -183,18 +183,21 @@
     onSubmit({formData}) {
         const url = this.props.resource.url;
         hypermediaClient.submitResource(this.props.method, url, formData)
-            .then(data => {
-                if (data.hasOwnProperty('errors')) {
-                    // XXX better update formData to `.addError` inline...
-                    this.setState({errors: data.errors});
-                    return;
+            .then(response => {
+                if (response.headers.get('content-type').toLowerCase()
+                    .indexOf('application/problem+json') >= 0) {
+                    return response.json().then(data => {
+                        this.setState({errors: getErrors(data)});
+                    });
                 }
-                if (this.props.method === 'PUT') {
-                    this.props.updateResource(data);
-                } else {
-                    this.props.updateResource();
-                }
-                this.context.router.history.push(url);
+                return response.json().then(data => {
+                    if (this.props.method === 'PUT') {
+                        this.props.updateResource(data);
+                    } else {
+                        this.props.updateResource();
+                    }
+                    this.context.router.history.push(url);
+                });
             });
     }
 
@@ -207,6 +210,7 @@
                 onSubmit={this.onSubmit}
                 schema={this.state.schema}
                 formData={this.state.formData}
+                errors={this.state.errors}
             />
         );
     }
--- a/src/services/hypermedia.js	Tue Jul 04 12:08:35 2017 +0200
+++ b/src/services/hypermedia.js	Tue Jul 04 15:54:23 2017 +0200
@@ -106,10 +106,10 @@
             headers: {Accept: 'application/json'}});
         return this.fetch(fullUrl, options)
             .then(response => {
-                const contentType = response.headers.get("content-type");
-                if(response.status === 204) {
-                    return response;
-                } else if (contentType && contentType.toLowerCase().indexOf("application/json") >= 0) {
+                const contentType = (response.headers.get('content-type') || '').toLowerCase();
+                if(response.status === 204 ||
+                        contentType.indexOf('application/json') >= 0 ||
+                        contentType.indexOf('application/problem+json') >= 0) {
                     return response;
                 }
                 const method = options.method || 'GET';
@@ -126,7 +126,7 @@
             },
             body: JSON.stringify(data),
         };
-        return this.jsonFetch(url, options);
+        return this.jsonFetchResponse(url, options);
     }
 
     deleteResource(url) {
--- a/src/utils.js	Tue Jul 04 12:08:35 2017 +0200
+++ b/src/utils.js	Tue Jul 04 15:54:23 2017 +0200
@@ -72,3 +72,25 @@
     //   returning the data value is a correct behaviour.
     return wrappedData.value();
 }
+
+export function getErrors(problem) {
+    // return a list of error message from a application/json+problem formatted
+    // response
+    const errors = [];
+    let error;
+    if (problem.hasOwnProperty('title')) {
+        error = problem.title;
+    } else {
+        error = 'An unexpected error occurred';
+    }
+    if (problem.hasOwnProperty('detail')) {
+        error += ': ' + problem.detail;
+    }
+    errors.push(error);
+    if (problem.hasOwnProperty('invalid-params')) {
+        for (const param of problem['invalid-params']) {
+            errors.push(param.name + ': ' + param.reason);
+        }
+    }
+    return errors;
+}
--- a/src/utils.spec.js	Tue Jul 04 12:08:35 2017 +0200
+++ b/src/utils.spec.js	Tue Jul 04 15:54:23 2017 +0200
@@ -1,6 +1,6 @@
 import {expect} from 'chai';
 
-import {appendPath, buildFormData} from './utils';
+import {appendPath, buildFormData, getErrors} from './utils';
 import {mapToSchema} from "./jsonaryutils";
 
 const userEditionSchema = {
@@ -139,3 +139,19 @@
         expect(formData).to.deep.equal(expected);
     });
 });
+
+describe('getErrors', () => {
+    it('should return an errors array suitable for alerting the user', () => {
+        expect(getErrors({})).to.eql(['An unexpected error occurred']);
+        expect(getErrors({
+            title: 'Internal Server Error',
+            detail: 'contact admin@example.com'})).to.eql(['Internal Server Error: contact admin@example.com']);
+        expect(getErrors({
+            title: 'Unprocessable Entity',
+            detail: 'some relations violate a unicity constraint',
+            'invalid-params': [{name: 'name', reason: 'name is part of violated unicity constraint'}],
+        })).to.eql([
+            'Unprocessable Entity: some relations violate a unicity constraint',
+            'name: name is part of violated unicity constraint']);
+    });
+});