Generierung von UI und Requests anhand eines JSON Schema
Published on 03/12/2017
11 min read
In category
web
In diesem Artikel möchte ich veranschaulichen, wie anhand eines JSON Schema eine UI dynamisch generiert werden kann. Diese UI soll dann bei der Zusammenstellung eines Modells dienen, welches wiederum als Basis für die Erstellung eines Request-Objekts herangezogen wird. Damit lässt sich eine dynamische Oberfläche für eine REST-Schnittstelle erstellen, welches mind. JSON unterstützt. Dieser Anwendungsfall ist dann hilfreich, wenn die REST-Schnittstelle eine komplexe Domain-Struktur besitzt und die Mittel von swagger-ui nicht ausreichen.
Einführung
Mittels REST lassen sich sehr schnell und auch komplexe Schnittstellen erstellen. Wenn nicht jedesmal die Schnittstellen-Beschreibung, falls eine aktuelle existiert, konsultiert werden soll, wie die Request-Objekte genau auszusehen haben, ist ein Werkzeug für die Generierung solcher Request-Objekte hilfreich. Swagger unterstützt hier, indem anhand der REST Definition eine einfache UI generiert wird. Dies ist bei komplexen Domain-Strukturen mit mehreren Ebenen, jedoch nicht ausreichend.
Ein hilfreiches Werkzeug wäre, wenn anhand der Schnittstellen-Definition ein Editor generiert werden kann, der bei der Erstellung der Request-Objekte hilfreich ist. Dabei sollte an der Schnittstellen-Definition so wenig wie möglich manuell angepasst werden müssen. Dies reduziert den (manuellen) Aufwand und vereinfacht die Integration in automatischen Prozesse/Pipelines. Die Verwendung eines JSON Schema ist hier sinnvoll, da hier auch Informationen bzgl. Typ und Validierung bereitgestellt werden. Diese kann solch ein Editor beachten um die Eingabe entsprechend einzuschränken.
Für dieses Werkzeug wird folgende Lösungsvariante ausgewählt:
- Aus den JAXB Klassen der REST-Schnittstellen wird zusätzlich ein JSON Schema generiert
- dieses JSON Schema wird als Basis verwendet, um eine UI zu generieren
- Anhand der UI könnnen die Request-Objekte erstellt werden
- die generierten Request-Objekte werden gegen die REST-Schnittstelle angewendet
Hierfür werden folgende Tools verwendet
- Jackson: für die Generierung des JSON Schema
- angular-schema-form und json-editor für die Generierung der UI aus dem JSON Schema
- Ace für einen schöneren Editor.
Die zwei Editoren haben sich als die stabilsten in den Tests erwiesen und besitzen ein paar unterschiedliche Features auf die später eingegangen wird.
Das resultierende Projekte SchemaUI ist in GitHub zu finden.
Integration
Backend
Als Basis wird ein JSON Schema benötigt. Dieses soll aus den JAXB Klassen generiert werden. Jackson bietet hierfür entsprechende Funktionalitäten an.
public class JsonSchemaGenerator {
public static String generateSchema(Class<?> clazz) {
final SchemaFactoryWrapper visitor = new SchemaFactoryWrapper();
final ObjectMapper mapper = new ObjectMapper();
try {
visitor.setVisitorContext(new VisitorContextWithoutSchemaInlining());
mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
// write date as string instead of timestamps
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
// Overwrite the standard DateSerializer which use DATE_TIME as type in json
// in our case is only DATE wanted
SimpleModule dateModule = new SimpleModule();
dateModule.addSerializer(Date.class, new DateFormatSerializer());
mapper.registerModule(dateModule);
mapper.acceptJsonFormatVisitor(mapper.constructType(clazz), visitor);
final com.fasterxml.jackson.module.jsonSchema.JsonSchema jsonSchema = visitor.finalSchema();
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonSchema);
} catch (JsonMappingException jsonEx) {
} catch (JsonProcessingException jsonEx) {
}
return null;
}
public static class DateFormatSerializer extends DateSerializer {
private static final long serialVersionUID = -2118707665648638640L;
@Override
protected void _acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint, boolean asNumber)
throws JsonMappingException {
if (asNumber) {
visitIntFormat(visitor, typeHint, JsonParser.NumberType.LONG, JsonValueFormat.UTC_MILLISEC);
} else {
visitStringFormat(visitor, typeHint, JsonValueFormat.DATE);
}
}
}
public static class VisitorContextWithoutSchemaInlining extends VisitorContext {
@Override
public String getSeenSchemaUri(JavaType aSeenSchema) {
// Avoid any references
return null;
}
}
}
JSON Schema erlaubt die Referenzierung von Typen, damit kommen die meisten UI-Generatoren nicht zu recht, bzw. erwarten diese in einem bestimmten Format. Dies kann man umgehen, in dem keine Referenzierung bei der Generierung des Schemas angewendet wird, dafür ist der VisitorContextWithoutSchemaInlining der dafür sorgt, dass kein Typ als schon bekannt identifiziert wird.
Die zusätzliche (fachliche) Anpassung ist, dass in dem generierten Schema nur das Datums-Format ohne Zeitangaben benötigt wird (DateFormatSerializer). Dadurch wird später in der UI auch nur ein Kalender ohne Zeitangaben angeboten.
Um die Ermittlung des JSON Schema später zu vereinfachen wird, analog SOAP/WSDL, das Schema bei der Anfrage mit ?json zurückgeliefert.
@RequestMapping(method = RequestMethod.GET, path = "/add", params = { "json" })
public @ResponseBody String schemaAddVehicles(@RequestParam("json") String json) {
return JsonSchemaGenerator.generateSchema(StockVehicleRequest.class);
}
Schema
Für das Beispiel wird eine Domain erstellt, welches unterschiedliche Typen und Ebenen besitzt. Das resultierende JSON Schema sieht beispielhaft wie folgt:
{
"type" : "object",
"id" : "urn:jsonschema:com:haddouti:schemaui:domain:StockVehicleRequest",
"properties" : {
"userId" : {
"type" : "string"
},
"vehicles" : {
"type" : "array",
"items" : {
"type" : "object",
"id" : "urn:jsonschema:com:haddouti:schemaui:domain:Vehicle",
"properties" : {
"id" : {
"type" : "integer"
},
"title" : {
"type" : "string"
},
"vehicleConfig" : {
"type" : "object",
"id" : "urn:jsonschema:com:haddouti:schemaui:domain:VehicleConfiguration",
"properties" : {
"firstRegistrationDate" : {
"type" : "string",
"format" : "date"
},
"mileage" : {
"type" : "number"
},
"year" : {
"type" : "integer"
},
"equipments" : {
"type" : "array",
"items" : {
"type" : "object",
"id" : "urn:jsonschema:com:haddouti:schemaui:domain:CodeText",
"properties" : {
"code" : {
"type" : "string"
},
"text" : {
"type" : "string"
}
}
}
}
}
},
"vehicleMarketing" : {
"type" : "object",
"id" : "urn:jsonschema:com:haddouti:schemaui:domain:VehicleMarketing",
"properties" : {
"specialCampaign" : {
"type" : "string",
"enum" : [ "Y", "N" ]
},
"campaigns" : {
"type" : "array",
"items" : {
"type" : "object",
"id" : "urn:jsonschema:com:haddouti:schemaui:domain:VehicleMarketing:VehicleMarketingCampaign",
"properties" : {
"title" : {
"type" : "string"
},
"desc" : {
"type" : "string"
},
"createdAt" : {
"type" : "string",
"format" : "date"
},
"limitedAt" : {
"type" : "string",
"format" : "date"
}
}
}
}
}
}
}
}
}
}
}
angular-schema-form
angular-schema-form bietet ein Werkzeug an, welches anhand eines JSON Schema dynamsich die UI generiert. Dabei kann zu einem JSON Schema noch eine Form-Definition bereitgestellt werden. Damit lässt sich direkt auf die Generierung Einfluss nehmen, um z.B. Typen zu ändern, Beziehung zwischen Elementen zu erstellen (für Details siehe deren Wiki). Die Eingaben in der UI werden dann in einem Modell gehalten.
<!-- List holding the available Interfaces -->
<select class="form-control" id="availableSchemaId"
ng-model="selectedSchema"
ng-options="obj.name for obj in schemaSelection">
<!-- Form holding the generated components -->
<form name="ngform" sf-model="dto.model" sf-form="dto.form" sf-schema="dto.schema"></form>
<!-- Multiple editors for Form-Definition, Schema, Model and Response -->
<div ui-ace="{ theme: 'monokai',mode:'json'}"
ng-class="{red: !isUIDefValid}" ng-model="uiDefinition" class="form-control form"></div>
<div ui-ace="{ theme: 'monokai',mode:'json'}"
ng-class="{red: !isSchemaDefValid}" ng-model="schemaDefinition" class="form-control schema"></div>
<div ui-ace="{ theme: 'monokai',mode:'json'}"
ng-model=modelDefinition class="form-control model"></div>
<div ui-ace="{ theme: 'monokai',mode:'json'}"
ng-model=response class="form-control model"></div>
<script type="text/javascript">
var app = angular.module('myModule', ['schemaForm', 'ui.ace', 'ui.bootstrap'])
.controller('FormController', function($scope, $http) {
$scope.schemaSelection = [
{ name: "StockVehicle", schemaUrl: '/stock/v1/add?json', url: '/stock/v1/add', form: ["*"], schema: undefined },
{ name: "SearchVehicle", schemaUrl: '/search/v1/detail?json', url: '/search/v1/detail', form: undefined, schema: undefined },
];
// schema form model
$scope.dto = {
schema : {},
form: [
"*"
],
model: {}
};
$scope.decorator = 'bootstrap-decorator';
$scope.uiDefinition = {};
$scope.schemaDefinition = {};
$scope.modelDefinition = {};
$scope.response = {};
$scope.responseAvailable = false;
$scope.selectedSchema = {};
$scope.isSchemaDefValid = true;
$scope.isUIDefValid = true;
// ************ Observer *****************
$scope.$watch('selectedSchema',function(val,old){
if(val && val !== old) {
$http.get(val.schemaUrl)
.then(
function (response) {
val.schema = response.data;
setNewData(val);
},
function (response) {
$scope.dto.schema = response.statusText;
}
);
}
});
$scope.$watch('schemaDefinition',function(val,old){
if (val && val !== old) {
try {
$scope.dto.schema = JSON.parse($scope.schemaDefinition);
$scope.isSchemaDefValid = true;
} catch (e){
$scope.isSchemaDefValid = false;
}
}
});
$scope.$watch('uiDefinition',function(val,old){
if (val && val !== old) {
try {
$scope.form = JSON.parse($scope.uiDefinition);
$scope.isUIDefValid = true;
} catch (e){
$scope.isUIDefValid = false;
}
}
});
// **************** Utilities *****************
var setNewData = function(data) {
$scope.dto.schema = data.schema;
$scope.dto.form = data.form || ["*"];
$scope.schemaDefinition = JSON.stringify($scope.dto.schema,undefined,2);
$scope.uiDefinition = JSON.stringify($scope.dto.form,undefined,2);
$scope.dto.model = data.model || {};
$scope.response = JSON.stringify({}, undefined, 2);
};
$scope.pretty = function(){
$scope.modelDefinition = JSON.stringify($scope.dto.model, undefined, 2);
return typeof $scope.dto.model === 'string' ? $scope.dto.model : JSON.stringify($scope.dto.model, undefined, 2);
};
$scope.send = function() {
$scope.selectedSchema
var data = $scope.dto.model;
var config = {
headers : {
'Content-Type': 'application/json'
}
}
$http.post($scope.selectedSchema.url, data, config)
.then(
function (response) {
$scope.response = JSON.stringify(response.data, undefined, 2);
$scope.responseAvailable = true;
},
function (response) {
$scope.response = response.statusText;
$scope.responseAvailable = true;
}
);
};
// ******* Init *************
// Init
setNewData($scope);
});
</script>
In dem Code-Beispiel sind die wichtigsten Fragmente aufgeführt.
- 4-6: Hier wird eine Liste angeboten, damit zwischen unterschiedlichen Schnittstellen gewechselt werden kann. Details später in Zeile 25f und 60ff.
- 10: Dieses Form wird von angular-schema-form benötigt, mit der Beziehung zu den unterschiedlichen Angular-Modellen für Form, Schema und Model.
- 13-20: Unterschiedliche Editoren, welche die einzelnen Modelle anzeigen. Eine Änderung im Schema-Editor hat sofortige Auswirkung auf die UI und wird neu generiert.
- 25-28: Eine Datenstruktur für die unterschiedlichen Schnittstellen, URL zum Schema und URL zum REST-Endpunkt.
- 60-75: Sobald die Liste der Schnittstellen verändert wird, wird das JSON Schema angefragt und die internen Modelle damit aktualisiert.
- 115ff: Hier wird der erstellte Request abgeschickt
angular-schema-form benötigt viele externe Bibliotheken, so dass die Lösung nicht mehr leichtgewichtig ist. Die Integration und Handhabung funktioniert jedoch sehr gut, so dass keine manuellen Anpassung an der UI oder Api durchgeführt werden muss.
json-editor
json-editor unterstützt den selben Anwendungsfall und erwartet auch ein JSON Schema.
<!-- List holding the available Interfaces -->
<select class="form-control" id="availableSchemaId"
ng-model="selectedSchema"
ng-options="obj.name for obj in schemaSelection">
<!-- Form holding the generated components -->
<div class='row'> <div id='editor_holder' class='medium-12 columns'></div> </div>
<!-- Multiple editors for Form-Definition, Schema, Model and Response -->
<div ui-ace="{ theme: 'monokai',mode:'json'}"
ng-class="{red: !isUIDefValid}" ng-model="uiDefinition" class="form-control form"></div>
<div ui-ace="{ theme: 'monokai',mode:'json'}"
ng-class="{red: !isSchemaDefValid}" ng-model="schemaDefinition" class="form-control schema"></div>
<div ui-ace="{ theme: 'monokai',mode:'json'}"
ng-model=modelDefinition class="form-control model"></div>
<div ui-ace="{ theme: 'monokai',mode:'json'}"
ng-model=response class="form-control model"></div>
<script type="text/javascript">
var app = angular.module('myModule', ['schemaForm', 'ui.ace', 'ui.bootstrap'])
.controller('FormController', function($scope, $http) {
$scope.schemaSelection = [
{ name: "StockVehicle", schemaUrl: '/stock/v1/add?json', url: '/stock/v1/add', form: ["*"], schema: undefined },
{ name: "SearchVehicle", schemaUrl: '/search/v1/detail?json', url: '/search/v1/detail', form: undefined, schema: undefined },
];
// schema form model
$scope.dto = {
schema : {},
form: [
"*"
],
model: {}
};
$scope.decorator = 'bootstrap-decorator';
$scope.uiDefinition = {};
$scope.schemaDefinition = {};
$scope.modelDefinition = {};
$scope.response = {};
$scope.responseAvailable = false;
$scope.selectedSchema = {};
$scope.isSchemaDefValid = true;
$scope.isUIDefValid = true;
// ************ Observer *****************
$scope.$watch('selectedSchema',function(val,old){
if(val && val !== old) {
$http.get(val.schemaUrl)
.then(
function (response) {
val.schema = response.data;
setNewData(val);
// JSON-Editor
$scope.reloadJsonEditor();
},
function (response) {
$scope.dto.schema = response.statusText;
}
);
}
});
$scope.$watch('schemaDefinition',function(val,old){
if (val && val !== old) {
try {
$scope.dto.schema = JSON.parse($scope.schemaDefinition);
$scope.isSchemaDefValid = true;
} catch (e){
$scope.isSchemaDefValid = false;
}
}
});
$scope.$watch('uiDefinition',function(val,old){
if (val && val !== old) {
try {
$scope.form = JSON.parse($scope.uiDefinition);
$scope.isUIDefValid = true;
} catch (e){
$scope.isUIDefValid = false;
}
}
});
// **************** Utilities *****************
var setNewData = function(data) {
$scope.dto.schema = data.schema;
$scope.dto.form = data.form || ["*"];
$scope.schemaDefinition = JSON.stringify($scope.dto.schema,undefined,2);
$scope.uiDefinition = JSON.stringify($scope.dto.form,undefined,2);
$scope.dto.model = data.model || {};
$scope.response = JSON.stringify({}, undefined, 2);
};
$scope.pretty = function(){
$scope.modelDefinition = JSON.stringify($scope.dto.model, undefined, 2);
return typeof $scope.dto.model === 'string' ? $scope.dto.model : JSON.stringify($scope.dto.model, undefined, 2);
};
$scope.send = function() {
$scope.selectedSchema
var data = $scope.dto.model;
var config = {
headers : {
'Content-Type': 'application/json'
}
}
$http.post($scope.selectedSchema.url, data, config)
.then(
function (response) {
$scope.response = JSON.stringify(response.data, undefined, 2);
$scope.responseAvailable = true;
},
function (response) {
$scope.response = response.statusText;
$scope.responseAvailable = true;
}
);
};
$scope.reloadJsonEditor = function() {
if(jsonEditor) jsonEditor.destroy();
jsonEditor = new JSONEditor(document.getElementById("editor_holder"),{
schema: $scope.dto.schema
});
window.jsonEditor = jsonEditor;
// When the value of the editor changes, update the JSON output and validation message
jsonEditor.on('change',function() {
var json = jsonEditor.getValue();
$scope.dto.model = json;
$scope.pretty();
// AngularJS needs some trigger to apply the changes
$scope.$apply();
});
};
// ******* Init *************
// Init
setNewData($scope);
// Set default options
JSONEditor.defaults.options.theme = 'bootstrap2';
//JSONEditor.defaults.options.theme = 'foundation6';
// Initialize the editor
var jsonEditor = new JSONEditor(document.getElementById("editor_holder"),{
schema: {
type: "object",
properties: {
name: { "type": "string" }
}
}
});
// Set the value
jsonEditor.setValue({
name: "John Smith"
});
// Listen for changes
jsonEditor.on("change", function() {
// Do something...
$scope.dto.model = jsonEditor.getValue();
});
});
</script>
Die grobe Struktur ist Analog geblieben. Nur die json-editor relevanten Anpassungen werden hier aufgeführt:
- 10: Hier wird json-editor seine UI (Editor genannt) integrieren.
- 71: Bei einer Aktualisierung des Schema muss json-editor explizit neu initiiert werden.
- 144-163: Das initiieren des Editors. Dabei muss AngularJS explizit signalisiert werden, das sich das Modell geändert hat, damit die UI-Komponente sich auch aktualisiert.
- 171ff: Diese Zeilen werden immer benötigt, da hier json-editor konfiguriert und initialisiert wird.
json-editor ist in der Handhabung leichter und kommt mit einem sehr kleinen Footprint. Ein Feature, wovon json-editor sich von angular-schema-form unterscheidet, ist dass Änderungen im Modell auch Auswirkung auf die generierte UI haben. Somit lässt sich der Weg zurück - Request zur UI - realisieren.
Fazit
In dem Artikel wurde aufgezeigt, wie sich eine dynamische UI anhand eines JSON Schema erstellen lässt. Anpassungen an die REST Schnittstelle oder gar an dem Schema direkt wurden nicht durchgeführt. Die erstellten Request-Objekte konnten erfolgreich von den REST Schnittstellen verarbeitet werden.
Mit diesen Werkzeugen lassen sich handliche Oberflächen bauen, die auf neue Anforderungen und Schnittstellen-Änderungen automatisch sich anpassen.
Das Projekt ist bei GitHub zu finden: SchemaUI.