Как бекенд с фронтендом дружить. Angular, интерфейсы и json schema validation

Запись от 02.10.2017

Эпилог

Данная статья не отрицает необходимости тестирования фронденда и бекенда, а описывает, как можно тестировать эту грань между фронтендом и бекендом. В частности, речь про AJAX-запросы к серверу.

Для расширения кругозора сервисы по теме: Postman, SoapUI.
Наша штука будет попроще, это её и плюс и минус одновременно. А выглядит вот так:



Проблемы, которые предлагается решить

Первая, самая ощутимая: изменились данные, приходящие от сервера по структуре.
Например, сначала нам приходил JSON с книгами вида:

[{
  "id": 1,
  "title": "Как писать на react и не сойти с ума",
}, ...]

Но потом, в результате рефакторинга или ещё по каким-то причинам "title" превращается в "name". С точки зрения бекенда данные уходят и вроде даже не поменялись, но на фронтенде — это катастрофа, в шаблонах этого поля нет, да и код в целом не рассчитан, что может быть какой-то "name" и/или не быть "title" у книги. Вообще, таких примеров можно привести миллион, но суть, я надеюсь, понятна.

Вторая проблема, на сколько я знаю, наиболее актуальна для PHP, многие фреймворки выдают данные строками. Всё — id, name, priceValue и т.д. И не важно TypeScript у нас или нет, с этими данными надо работать, и если в priceValue пришла строка, мы её уже ни с чем просто так не сложим, а поиск элементов по строковому id через "===" не увенчается успехом. Так что, соблюдать типы очень важно для надежной работы приложения.


TypeScript помогает

То что Angular ориентирован на TypeScript крайне поможет для решения наших проблем. Мы пишем интерфейсы для объектов с которыми работаем. В том числе, и для тех, что ждем от бекенда.

Нехитрым образом мы можем преобразовать интерфейсы в JSON-файлы, аннотирующие наши объекты, проще говоря: JSON Schema, о которых почитать подробно можно тут: http://json-schema.org/

Ну а поскольку у нас есть и данные с сервера и JSON со схемами ожидаемых ответов остается только сравнить эти икс и игрик. В общем, такие решения гуглятся по запросу "js json schema validation".


Практическая часть

Для генерации файлов JSON Schema я беру библиотеку https://github.com/YousefED/typescript-json-schema/:

npm i typescript-json-schema --save-dev

И модули для запуска ts-файлов:

npm install -g typescript
npm install -g ts-node

Теперь надо написать скрипт, который, запустит генерацию json-файлов, но прежде, для удобства масштабирования создам файл /src/app/shared/api-schemas.ts с таким, примерно, содержанием:

import {BookItem} from './example/book-item';
import {AuthorItem} from './example/author-item';

/* tslint:disable */
export interface author__getList {
  success: boolean;
  items: AuthorItem[];
}

export interface book__getList {
  success: boolean;
  items: BookItem[];
}
/* tslint:enable */

export const SCHEME_LIST: string[] = [
  'author__getList',
  'book__getList',
];

Не обращайте внимания на странное именование интерфейсов, они нужным нам только для генерации json-схем валидации. А tslint отключается, что бы линтер не ругался на эти названия. Если вы знаете, как отключить проверку только названий интерфейсов, напишите, пожалуйста, в комментариях.

В корне проекта, рядом с package.json создаю файл "tjs-generator.ts" с таким содержимым

import {resolve} from 'path';
import * as TJS from 'typescript-json-schema';
import * as fs from 'fs';
import {SCHEME_LIST} from './src/app/shared/api-schemas';

const settings: TJS.PartialArgs = {
  required: true,
};

const program = TJS.getProgramFromFiles(
  [resolve('./src/app/shared/api-schemas.ts')], { strictNullChecks: true }
);

// В цикле создаются файлы в /ajax/scheme/.. с описанием схем данных
SCHEME_LIST.forEach(schemeName => {
  const schema = TJS.generateSchema(program, schemeName, settings);

  fs.writeFileSync(
    `../www/api/ scheme/${schemeName}.json`,
    JSON.stringify(schema, null, 2), 'utf-8'
  );
});

Теперь можно выполнить команду в директории с этим файлом:

ts-node tjs-generator.ts


После выполнения у вас появятся файлы типа scheme/author__getList.json и т.д. Загружаем их на сервер.


Валидация данных

Теперь дело за валидацией данных. Ставим ajv в наш package.json:

npm install ajv

Работать он будет в отдельном компоненте для бекендера, который сможет себя проверять «а ничего ли я сейчас на фронте не сломал». То есть, выглядит это как одна кнопка Run и лог с ошибками, если возникнут.

Полностью приводить здесь полный код компонента, стили, разметку я не стану. Если вы дошли до этого шага, то проблем с версткой точно не возникнет :) опишу лишь основные моменты использования, тем более, что в компоненте можно описать и какую-то бизнес-логику. Например, выбрать товары каталога, добавить какой-нибудь в корзину, проверить ответ, удалить товар из корзины, снова проверить ответ и т.д.

Короче, код компонента в минимальном виде такой:

import {Component} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject} from 'rxjs/BehaviorSubject';
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/observable/combineLatest';

import * as Ajv from 'ajv';
import {SCHEMA_DRAFT_04} from './json-schema-draft-04';
import {SCHEME_LIST} from '../api-schemas';
const ajv = new Ajv();
ajv.addMetaSchema(SCHEMA_DRAFT_04);

interface LogEntity {
  group: string;
  priority: number;
  text: string;
}

@Component({
  selector: 'app-ajax-test-compile',
  templateUrl: './ajax-test-compile.component.html',
  styleUrls: ['./ajax-test-compile.component.scss'],
})
export class AjaxTestCompileComponent {
  public  log: LogEntity[] = [];
  private schemeList = SCHEME_LIST;

  constructor(private httpClient: HttpClient) {}

  /** Метод запускается кнопкой из шаблона компонента */
  public runTest(): void {
    this.log = [];

    this.schemeList.forEach(action => this.checkAjaxSchemeByAction(action));
  }

  private checkAjaxSchemeByAction(action: string): void {
    const loadScheme = this.httpClient.get(`/ajax/scheme/${action}.json`);
    const loadData   = this.httpClient.post(`/ajax/?action=${action}`, {});

    Observable.combineLatest(loadScheme, loadData).subscribe(res => {
      if (ajv.validate(res[0], res[1])) {
        this.putLog(action, 4, `Action ${action}: scheme validation ok`);

      } else {
        this.putLog(action, 1, `Action ${action}: ${ajv.errorsText()}`);
        console.warn('ajv', ajv.errors);
      }
    }, error => {
      this.putLog(action, 1, `Action ${action}: answer is not JSON`);
      console.warn(`Action ${action}:`, error);
    });
  }

  /** Метод помещает события в лог */
  private putLog(group: string, priority: number, text: string): void {
    this.log.push({
      group: group,
      priority: priority,
      text: text,
    });
  }
}

Пару комментариев по коду. log используется для вывода в шаблоне компонента, что бы сразу видеть, что происходит, в каких методах ошибки и если надо — проследовать в консоль браузера и посмотреть network или консоль. Кстати, поэтому и console.warn(), что бы посмотреть трейс ошибки.