There are many ways to create a modern web application, but each team inevitably faces approximately the same set of questions: how to distribute front and back duties, how to minimize the appearance of duplicate logic - for example, when validating data, which libraries to use, how to ensure reliable and transparent transport between front and back and document the code.
In our opinion, we managed to implement a good example of a solution balanced in complexity and profit, which we successfully use in production based on Symfony and React.
What kind of data exchange format can we choose when planning the development of a backend API in an actively developing web product that contains dynamic forms with related fields and complex business logic?
')
- SWAGGER is a good option, there is documentation and handy tools for debugging. Especially for Symfony, there are libraries that allow you to automate the process, but unfortunately JSON Schema turned out to be preferable;
- JSON Schema - this option was offered by frontend developers. They already had libraries that allow them to display forms on its basis. This determined our choice. The format allows you to describe primitive checks that can be done in the browser. There is also documentation that describes all possible options for the scheme;
- GraphQL is pretty young. Not such a large number of server side and frontend libraries. At the time of the creation of the system was not considered, in the future - the best way to create an API, this will be a separate article;
- SOAP - has a strong data typing, the ability to build documentation, but it is not so easy to make friends with the React front. Also, SOAP has more overhead by the same usable amount of data transferred;
All these formats did not completely cover our needs, so I had to write my combine harvester. Such an approach can provide highly effective solutions for any particular application, but it carries risks:
- high probability of bugs;
- often not 100% documentation and test coverage;
- low “modularity” due to the closeness of the software API. Typically, such solutions are written as a monolith and do not imply a sharing between projects as components, as this requires a special architectural construction (read the cost of development);
- high level of entry of new developers. It can take a lot of time to understand all the coolness of a bike;
Therefore, it is good practice to use common and stable libraries (like the left-pad from npm) by rule - the best code is the one you never wrote, and you solved the business problem. Backend web solutions development in Rambler Group advertising technologies is conducted on Symfony. We will not dwell on all the components of the framework used, below we will talk about the main part, on the basis of which the work is implemented - the
Symfony form . React and the corresponding library extending JSON Schema for WEB specificity -
React JSON Schema Form - is used on the frontend.
General scheme of work:

This approach gives many advantages:
- documentation is generated out of the box, as is the ability to build automatic tests - again according to the scheme;
- all transmitted data is typed;
- it is possible to transmit information about the basic validation rules;
Fast integration of the transport layer into React - due to the Mozilla React JSON Schema library; - the ability to generate web components on the frontend out of the box due to bootstrap integration;
- logical grouping, a set of validations and possible values ​​of HTML elements, as well as all business logic is controlled at a single point - on the backend, there is no duplication of code;
- porting the application to other platforms as easily as possible - the view part is separated from the manager (see the previous paragraph); instead of React and the browser, the Android rendering and the iOS application can be used to render and process user requests;
Let's look at the components and their interaction scheme in more detail.
Initially,
JSON Schema allows you to describe primitive checks that can be done on the client, like binding or typing different parts of the scheme:
const schema = { "title": "A registration form", "description": "A simple form example.", "type": "object", "required": [ "firstName", "lastName" ], "properties": { "firstName": { "type": "string", "title": "First name" }, "lastName": { "type": "string", "title": "Last name" }, "password": { "type": "string", "title": "Password", "minLength": 3 }, "telephone": { "type": "string", "title": "Telephone", "minLength": 10 } } }
To work with the front-end scheme, there is a popular
React JSON Schema Form library, which provides the necessary add-ons for web development on the JSON Schema:
uiSchema - JSON Schema itself determines the type of parameters passed, but this is not enough to build a web application. For example, a field of type String can be represented as <input ... /> or as <textarea ... />, these are important nuances with regard to which you need to correctly draw the scheme for the client. To transfer these nuances, uiSchema also serves, for example, for the above JSON Schema, you can specify the visual web component of the following uiSchema:
const uiSchema = { "firstName": { "ui:autofocus": true, "ui:emptyValue": "" }, "age": { "ui:widget": "updown", "ui:title": "Age of person", "ui:description": "(earthian year)" }, "bio": { "ui:widget": "textarea" }, "password": { "ui:widget": "password", "ui:help": "Hint: Make it strong!" }, "date": { "ui:widget": "alt-datetime" }, "telephone": { "ui:options": { "inputType": "tel" } } }
Live Playground example
can be viewed here .
With this use of the scheme, rendering on the frontend will be implemented by standard components of bootstrap in several lines:
render(( <Form schema={schema} uiSchema={uiSchema} /> ), document.getElementById("app"));
If the standard widgets that are supplied with bootstrap do not suit you and you need customization - for some data types you can specify your templates in uiSchema, at the time of this writing,
string ,
number ,
integer ,
boolean are supported.
FormData - contains form data, for example:
{ "firstName": "Chuck", "lastName": "Norris", "age": 78, "bio": "Roundhouse kicking asses since 1940", "password": "noneed" }
After rendering, the widgets will be filled with this data - useful for editing forms, as well as for some custom mechanisms that we added for related fields and complex forms, see below.
More information about all the nuances of setting up and using the sections described above can be found on the
page of the plugin .
Out of the box, the library allows you to work only with these three sections, but for a full-fledged web application, you need to add some more features:
Errors - it is also necessary to be able to pass errors of various backend checks for drawing to the user, and errors can be either simple validation errors - for example, the uniqueness of a login when a user is registered, or more complex ones based on business logic - i.e. we should be able to customize their (errors) number and text of displayed notifications. For this, the Errors section was added to the transmitted data set, besides the ones described above - for each field a list of errors for drawing is defined here.
Action ,
Method - for sending the data prepared by the user to the backend, two attributes were added that contain the URL of the processing controller's backend and the HTTP delivery method
As a result, for communication between the front and backline json turned out with the following sections:
{ "action": "https://...", "method": "POST", "errors":{}, "schema":{}, "formData":{}, "uiSchema":{} }
But how to generate this data on the back end? At the time of the creation of the system there were no ready-made libraries to convert the Symfony Form into JSON Schema. Now they have already appeared, but have their drawbacks - for example,
LiformBundle treats JSON Schema fairly freely and changes the standard at its own discretion, therefore, unfortunately, I had to write my own implementation.
Standard
symfony forms are used as the basis for generation. It is enough to use the builder and add the required fields:
Sample form $builder ->add('title', TextType::class, [ 'label' => 'label.title', 'attr' => [ 'title' => 'title.title', ], ]) ->add('description', TextareaType::class, [ 'label' => 'label.description', 'attr' => [ 'title' => 'title.description', ], ]) ->add('year', ChoiceType::class, [ 'choices' => range(1981, 1990), 'choice_label' => function ($val) { return $val; }, 'label' => 'label.year', 'attr' => [ 'title' => 'title.year', ], ]) ->add('genre', ChoiceType::class, [ 'choices' => [ 'fantasy', 'thriller', 'comedy', ], 'choice_label' => function ($val) { return 'genre.choice.'.$val; }, 'label' => 'label.genre', 'attr' => [ 'title' => 'title.genre', ], ]) ->add('available', CheckboxType::class, [ 'label' => 'label.available', 'attr' => [ 'title' => 'title.available', ], ]);
At the output, this form is converted into a type scheme:
JsonSchema example { "action": "//localhost/create.json", "method": "POST", "schema": { "properties": { "title": { "maxLength": 255, "minLength": 1, "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" }, "year": { "enum": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "enumNames": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "object", "title": "label.available" } }, "required": [ "title", "description", "year", "genre", "available" ], "type": "object" }, "formData": { "title": "", "description": "", "year": "", "genre": "" }, "uiSchema": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:widget": "mainForm" } }
All code that converts forms to JSON is closed and is used only in the Rambler Group, if the community has an interest in this topic, we will
refactor it in the bundle format in our
github repository .
Let's look at a few more aspects without the implementation of which it is difficult to build a modern web application:
Validation of fields
It is defined using the
symfony validator , which describes the rules for object validation, an example of a validator:
<property name="title"> <constraint name="Length"> <option name="min">1</option> <option name="max">255</option> <option name="minMessage">title.min</option> <option name="maxMessage">title.max</option> </constraint> <constraint name="NotBlank"> <option name="message">title.not_blank</option> </constraint> </property>
In this example, constrain of type NotBlank modifies the schema by adding a field to the array of required schema fields, and constrain of type Length adds schema-> properties-> title-> maxLength and schema-> properties-> title-> minLength attributes that validation should already take into account on the front end.
Item grouping
In real life, simple forms are the exception to the rule. For example, a project may have a form with a large number of fields and giving everything in a solid list is not the best option - we need to take care of the users of our application:

The obvious solution is to divide the form into logical groups of control elements so that the user can more easily navigate and make fewer mistakes:

As you know, the capabilities of the Symfony Form out of the box are quite large - for example, forms can be inherited from other forms, this is convenient, but in our case there are disadvantages. In the current implementation, the order in JSON Schema determines the order of drawing a form element in the browser, inheritance may violate this order. One option was to group items, for example:
Sample Nested Form $info = $builder ->create('info',FormType::class,['inherit_data'=>true]) ->add('title', TextType::class, [ 'label' => 'label.title', 'attr' => [ 'title' => 'title.title', ], ]) ->add('description', TextareaType::class, [ 'label' => 'label.description', 'attr' => [ 'title' => 'title.description', ], ]); $builder ->add($info) ->add('year', ChoiceType::class, [ 'choices' => range(1981, 1990), 'choice_label' => function ($val) { return $val; }, 'label' => 'label.year', 'attr' => [ 'title' => 'title.year', ], ]) ->add('genre', ChoiceType::class, [ 'choices' => [ 'fantasy', 'thriller', 'comedy', ], 'choice_label' => function ($val) { return 'genre.choice.'.$val; }, 'label' => 'label.genre', 'attr' => [ 'title' => 'title.genre', ], ]) ->add('available', CheckboxType::class, [ 'label' => 'label.available', 'attr' => [ 'title' => 'title.available', ], ]);
This form will be converted to the following form:
JsonSchema Nested Example "schema": { "properties": { "info": { "properties": { "title": { "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" } }, "required": [ "title", "description" ], "type": "object" }, "year": { "enum": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "enumNames": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "object", "title": "label.available" } }, "required": [ "info", "year", "genre", "available" ], "type": "object" }
and corresponding uiSchema "uiSchema": { "info": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "ui:widget": "form" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:widget": "group" }
This method of grouping did not suit us as the form for data begins to depend on the presentation and cannot be used, for example, in API or other forms. It was decided to use additional parameters in uiSchema without breaking the current standard JSON Schema. As a result, additional options of the following type were added to the symphonic form:
'fieldset' => [ 'groups' => [ [ 'type' => 'base', 'name' => 'info', 'fields' => ['title', 'description'], 'order' => ['title', 'description'] ] ], 'type' => 'base' ]
This will be converted to the following scheme:
"ui:group": { "type": "base", "groups": [ { "type": "group", "name": "info", "title": "legend.info", "fields": [ "title", "description" ], "order": [ "title", "description" ] } ], "order": [ "info" ] },
Full version of schema and uiSchema "schema": { "properties": { "title": { "maxLength": 255, "minLength": 1, "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" }, "year": { "enum": [ "1989", "1990" ], "enumNames": [ "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "boolean", "title": "label.available" } }, "required": [ "title", "description", "year", "genre", "available" ], "type": "object" }
"uiSchema": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:group": { "type": "base", "groups": [ { "type": "group", "name": "info", "title": "legend.info", "fields": [ "title", "description" ], "order": [ "title", "description" ] } ], "order": [ "info" ] }, "ui:widget": "fieldset" }
Since on the frontend side
, the React library we use does not support this out of the box, we had to add this functionality ourselves. With the addition of the new element “ui: group”, we are able to fully control the process of grouping elements and forms using the current API.
Dynamic forms
What if one field depends on another, for example, a drop-down list of subcategories depends on the category selected?

Symfony FORM gives us the ability to create
dynamic forms using Event's, but unfortunately, at the time of its implementation, this feature was not supported by JSON Schema, although in recent versions
this feature has appeared . Initially, the idea was to give the entire list to the Enum and EnumNames object, based on which to filter the values:
{ "properties": { "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "sgenre": { "enum": [ "eccentric", "romantic", "grotesque" ], "enumNames": [ { "title": "sgenre.choice.eccentric", "genre": "comedy" }, { "title": "sgenre.choice.romantic", "genre": "comedy" }, { "title": "sgenre.choice.grotesque", "genre": "comedy" } ], "type": "string", "title": "label.genre" } }, "type": "object" }
But with this approach, for each such element, you will have to write your processing on the frontend, not to mention the fact that everything becomes very complicated when these objects become several or one element depends on several lists. In addition, the amount of data sent to the frontend for correct processing and rendering of all dependencies grows strongly. For example, imagine drawing a form consisting of three fields interconnected - countries, cities, streets. The amount of initial data that needs to be sent to the frontend backend can upset thin clients, and, as you remember, we need to take care of our users. Therefore, it was decided to implement the dynamics by adding custom attributes:
- SchemaID is a schema attribute that contains the address of the controller for processing the current entered FormData and updating the schema of the current form, if business logic requires it;
- Reload - an attribute that tells the frontend that changing this field initiates the update of the schema, sending the form data to the backend;
The presence of
SchemaID may seem like duplication - there is an
action attribute, but here we are talking about sharing responsibility - the
SchemaID controller
is responsible for the intermediate update of the
schema and
UISchema , and the
action controller performs the necessary business action - for example, it creates or updates an object and does not allow sending a part of the form because performs validation checks. With these additions, the scheme starts to look like this:
{ "schemaId": "//localhost/schema.json", "properties": { "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "sgenre": { "enum": [], "enumNames": [], "type": "string", "title": "label.sgenre" } }, "uiSchema": { "genre": { "ui:options": { "reload": true }, "ui:widget": "select", "ui:help": "title.genre" }, "sgenre": { "ui:widget": "select", "ui:help": "title.sgenre" }, "ui:widget": "mainForm" }, "type": "object" }
In case of changing the “genre” field, the frontend sends the entire form with the current data entered to the backend, receives in response a set of sections necessary for drawing the form:
{ action: “https://...”, method: "POST", schema:{} formData:{} uiSchema:{} }
and renders instead of the current form. What exactly changes after sending is determined by the back-up, the composition or the number of fields can change, etc. - any change required by the business logic of the application.
Conclusion
Due to a small expansion of the standard approach, we received a number of additional features that allow us to fully control the formation and behavior of front-end React components, build dynamic schemes based on business logic, have a single point of validation rules formation and the ability to quickly and flexibly create new VIEW parts - for example, mobile or desktop applications. Going into such bold experiments, you need to remember about the standard on the basis of which you work and maintain backward compatibility with it. Instead of React on the frontend, any other library can be used, the main thing is to write the transport adapter to JSON Schema and connect any form rendering library. Bootstrap worked well with React since we had experience with this technology stack, but the approach we have described does not limit you in choosing technologies. In place of symfony, there could also be any other framework that allows you to convert forms into the JSON Schema format.
Upd: you can see our report on
Symfony Moscow Meetup # 14 about it from 1:15:00.