Панель управления услугами. Часть 2. На пути к фронтенду +9


Вступление. Еще немного про api.


image


Итак, в прошлый раз мы остановились на описание процесса сборки api, с тех пор некоторые вещи успели измениться. А именно — Grunt была заменен на Gulp. Главная причина такой перестановки — скорость работы.


После перехода разница стала заметна невооруженным глазом (тем более, gulp выводит время, затраченное на каждую задачу). Достигается такой результат за счет того, что все задачи выполняются параллельно по умолчанию. Такое решение отлично нам подошло. Некоторые части работы, которую выполнял Grunt были независимы друг от друга, а значит их можно было выполнять одновременно. Например, добавление отступов для файлов definitions и paths.
К сожалению, без минусов не обошлось. Задачи, требующие выполнения предыдущих, остались. Но gulp имеет обширную базу пакетов на все случаи жизни, поэтому решение было найдено достаточно быстро — пакет runSequence


gulp.task('client', (callback) => {
    runSequence(
        'client:indent',
        'client:concat',
        'client:replace',
        'client:validate',
        'client:toJson',
        'client:clean',
        callback
    );
});

То есть вместо стандартного объявления задачи для gulp, аргументом передается callback, в котором задачи выполняются по указанному порядке. В нашем случае порядок выполнения был важен только для 4 задач из 40, поэтому прирост в скорости по сравнению с Grunt ощутим.


Также, gulp позволил отказаться от coffeescript в пользу ES6. Код изменился минимально, но отпала необходимость при нечастых изменениях в конфигурации сборки api вспоминать как писать на coffeescript, так как нигде более он не использовался.
Пример части конфигураций для сравнения:


Gulp


gulp.task('admin:indent_misc', () => {
    return gulp.src(`${root}/projects.yml`)
        .pipe(indent({
            tabs: false,
            amount: 2
            }))
        .pipe(gulp.dest(`${interimDir}`))
});

Grunt


indent: 
  admin_misc:
    src: [
      '<%= admin_root %>/authorization.yml'
      '<%= admin_root %>/projects.yml'
    ]
    dest: '<%= admin_interim_dir %>/'
    options:
      style: 'space'
      size: 2
      change: 1 


Также, стоит упомянуть о небольших граблях, на которые нам удалось наступить.
Они заключались в следующем: после генерации файлов api и попытки запуска angular-приложение выводилась ошибка повторного экспорта ResourceService. Отправной точкой для поиска стал файл api/model/models.ts. Он содержит экспорты всех интерфейсов и сервисов, которые используются в дальнейшем.


Здесь следует добавить небольшое отступление и рассказать как swagger-codegen присваивает имена интерфейсам и сервисам.


Небольшое отступление

Интерфейс
Исходя из шаблона интерфейса, если у свойства сущности указан тип object, то для него создается отдельные интерфейс, который именуется %Имя_сущностиИмя_свойства%.


Сервис
Исходя из шаблона сервиса имя сервиса состоит из имени тега и слова Service, например, OrderService. Поэтому, если указать у пути в спецификации несколько тегов, то этот метод попадет в несколько сервисов. Такой подход позволяет в одном случае импортировать только необходимый сервис и избежать импорта нескольких сервисов в другом.


Итак, в файле models.ts действительно присутствовало два экспорта ResourceService, один представлял сервиса для доступа к методам сущности resource, а второй — интерфейс для свойства service у сущности resource. Поэтому и возник такой конфликт. Решением стало переименование свойства.


От API к фронтенду.


image


Как я уже говорил, спецификация swagger позволяет сформировать необходимые файлы работы с api как для бекенда, так и для фронтенда. В нашем случае, генерация кода api для Angular2 выполняется с помощью простой команды:


java -jar ./swagger-codegen-cli.jar generate \ 
-i client_swagger.json -l typescript-angular -o ../src/app/api -c ./typescript_config.json

Разбор параметров:


  • java -jar ./swagger-codegen-cli.jar generate — запуск jar-файла swagger-codegen
  • -i client_swagger.json – файл спецификации, полученный в итоге работы Gulp
  • -l typescript-angular – язык, для которого выполняется генерация кода
  • -o ../src/app/api — целевая директория для файлов api
  • -c ./typescript_config.json – дополнительная конфигурация (для устранения проблема именования переменных, о которой я рассказывал в первой части)

Учитывая, что количество языков, а соответственно шаблонов и кода для генерации, огромно, периодически в голове появляется мысль пересобрать codegen только под наши нужды и оставить только Typescript-Angular. Тем более, сами разработчики предоставляют инструкции по добавлению собственных шаблонов.


Таким нехитрым образом мы получаем все необходимые модули, интерфейсы, классы и сервисы для работы с api.
Пример одного из интерфейсов, полученных с помощью codegen:


Входной файл спецификации service_definition.yaml
Service:
  type: object
  required:
    - id
  properties:
    id:
      type: integer
      description: Unique service identifier
      format: 'int32'
      readOnly: true
    date:
      type: string
      description: Registration date
      format: date
      readOnly: true
    start_date:
      type: string
      description: Start service date
      format: date
      readOnly: true
    expire_date:
      type: string
      description: End service date
      format: date
      readOnly: true
    status:
      type: string
      description: Service status
      enum:
        - 'empty'
        - 'allocated'
        - 'launched'
        - 'stalled'
        - 'stopped'
        - 'deallocated'
    is_primary:
      type: boolean
      description: Service primary state
    priority:
      type: integer
      description: Service priority
      format: 'int32'
      readOnly: true
    attributes:
      type: array
      description: Service attributes
      items:
        type: string
    primary_service:
      type: integer
      description: Unique service identifier
      format: 'int32'
      readOnly: true
      example: 138
    options:
      type: array
      items:
        type: string
    order:
      type: integer
      description: Unique order identifier
      format: 'int32'
      readOnly: true
    proposal:
      type: integer
      description: Unique proposal identifier
      format: 'int32'
      readOnly: true
    resources:
      type: array
      items:
        type: object
        properties:
          url:
            type: string
      description: Resources for this service
Services:
  type: array
  items:
    $ref: '#/definitions/Service'

На выходе получаем интерфейс, понятный angular’у
import { ServiceOptions } from './serviceOptions';
import { ServiceOrder } from './serviceOrder';
import { ServicePrimaryService } from './servicePrimaryService';
import { ServiceProposal } from './serviceProposal';
import { ServiceResources } from './serviceResources';

/**
 * Service entry reflects fact of obtaining some resources within order (technical part). 
 In other hand service points to proposal that was used for ordering (commercial part).
 Service can be primary (ordered using tariff proposal) and non-primary (ordered using option proposal).
 */
export interface Service {
    /**
     * Record id
     */
    id: number;

    /**
     * Service order date
     */
    date?: string;

    /**
     * Service will only be launched after this date (if nonempty)
     */
    start_date?: string;

    /**
     * Service will be stopped after this date (if nonempty)
     */
    expire_date?: string;

    /**
     * Service current status. Meaning:
        * empty - initial status, not allocated
        * allocated - all option services and current service are allocated and ready to launch
        * launched - all option services and current one launched and works
        * stalled - service can be stalled in any time. Options also goes to the same status
        * stopped - service and option services terminates their activity but still stay allocated
        * deallocated - resources of service and option ones are released and service became piece of history
     */
    status?: number;

    /**
     * Whether this service is primary in its order. Otherwise it is option service
     */
    is_primary?: boolean;

    /**
     * Optional priority in order allocating process. The less number the earlier service will be allocated
     */
    priority?: number;

    primary_service?: ServicePrimaryService;

    order?: ServiceOrder;

    proposal?: ServiceProposal;

    /**
     * Comment for service
     */
    comment?: string;

    /**
     * Service's cost  (see also pay_type, pay_period, onetime_cost)
     */
    cost?: number;

    /**
     * Service's one time payment amount
     */
    onetime_cost?: number;

    /**
     * Bill amount calculation type depending on service consuming
     */
    pay_type?: Service.PayTypeEnum;

    /**
     * Service bill payment period
     */
    pay_period?: Service.PayPeriodEnum;

    options?: ServiceOptions;

    resources?: ServiceResources;

}
export namespace Service {
    export enum PayTypeEnum {
        Fixed = <any> 'fixed',
        Proportional = <any> 'proportional'
    }
    export enum PayPeriodEnum {
        Daily = <any> 'daily',
        Monthly = <any> 'monthly',
        Halfyearly = <any> 'halfyearly',
        Yearly = <any> 'yearly'
    }
}

Выдержка из файла спецификации service_path.yml
/dedic/services:
  get:
    tags: [Dedicated, Service]
    x-swagger-router-controller: app.controllers.service
    operationId:  get_list
    security:
      - oauth: []
    summary: Get services list
    parameters:
      - $ref: '#/parameters/limit'
      - $ref: '#/parameters/offset'
    responses:
      200:
        description: Returns services
        schema:
          $ref: '#/definitions/Services'
        examples:
          application/json:
            objects:
            - id: 3
              date: '2016-11-01'
              start_date: '2016-11-02'
              expire_date: '2017-11-01'
              status: 'allocated'
              is_primary: true
              priority: 3
              primary_service: null
              options:
                url: "https://doc.miran.ru/api/v1/dedic/services/3/options"
              order:
                url: 'https://doc.miran.ru/api/v1/orders/3'
              comment: 'Test comment for service id3'
              cost: 2100.00
              onetime_cost: 1000.00
              pay_type: 'fixed'
              pay_period: 'daily'
              proposal:
                url: 'https://doc.miran.ru/api/v1/dedic/proposals/12'
              agreement:
                url: 'https://doc.miran.ru/api/v1/agreements/5'
              resorces:
                url: "https://doc.miran.ru/api/v1/dedic/services/3/resources"
            - id: 7
              date: '2016-02-12'
              start_date: '2016-02-12'
              expire_date: '2016-02-12'
              status: 'stopped'
              is_primary: true
              priority: 2
              primary_service: null
              options:
                url: "https://doc.miran.ru/api/v1/dedic/services/7/options"
              order:
                url: 'https://doc.miran.ru/api/v1/orders/7'
              comment: 'Test comment for service id 7'
              cost: 2100.00
              onetime_cost: 1000.00
              pay_type: 'fixed'
              pay_period: 'daily'
              proposal:
                url: 'https://doc.miran.ru/api/v1/dedic/proposals/12'
              agreement:
                url: 'https://doc.miran.ru/api/v1/agreements/2'
              resorces:
                url: "https://doc.miran.ru/api/v1/dedic/services/7/resources"
            total_count: 2
      500:
        $ref: "#/responses/Standard500"
  post:
    tags: [Dedicated, Service]
    x-swagger-router-controller: app.controllers.service
    operationId: create
    security:
      - oauth: []
    summary: Create service in order
    parameters:
      - name: app_controllers_service_create
        in: body
        schema:
          type: object
          additionalProperties: false
          required:
            - order
            - proposal
          properties:
            order:
              type: integer
              description: Service will be attached to this preliminary created order
              format: 'int32'
              minimum: 0
            proposal:
              type: integer
              format: 'int32'
              description: Proposal to be used for service. Tariff will create primary service, not tariff - option one
              minimum: 0
    responses:
      201:
        description: Service successfully created
      400:
        description: Incorrect order id (deleted or not found) or proposal id (expired or not found)

Выдержка из готового сервиса для Angular
/* tslint:disable:no-unused-variable member-ordering */

import { Inject, Injectable, Optional }                      from '@angular/core';
import { Http, Headers, URLSearchParams }                    from '@angular/http';
import { RequestMethod, RequestOptions, RequestOptionsArgs } from '@angular/http';
import { Response, ResponseContentType }                     from '@angular/http';

import { Observable }                                        from 'rxjs/Observable';
import '../rxjs-operators';

import { AppControllersServiceCreate } from '../model/appControllersServiceCreate';
import { AppControllersServiceUpdate } from '../model/appControllersServiceUpdate';
import { InlineResponse2006 } from '../model/inlineResponse2006';
import { InlineResponse2007 } from '../model/inlineResponse2007';
import { InlineResponse2008 } from '../model/inlineResponse2008';
import { InlineResponse2009 } from '../model/inlineResponse2009';
import { InlineResponse401 } from '../model/inlineResponse401';
import { Service } from '../model/service';
import { Services } from '../model/services';

import { BASE_PATH, COLLECTION_FORMATS }                     from '../variables';
import { Configuration }                                     from '../configuration';

@Injectable()
export class ServiceService {

    protected basePath = '';
    public defaultHeaders: Headers = new Headers();
    public configuration: Configuration = new Configuration();

    constructor(
        protected http: Http,
        @Optional()@Inject(BASE_PATH) basePath: string,
        @Optional() configuration: Configuration) {
        if (basePath) {
            this.basePath = basePath;
        }
        if (configuration) {
            this.configuration = configuration;
            this.basePath = basePath || configuration.basePath || this.basePath;
        }
    }

 /**
     *
     * Extends object by coping non-existing properties.
     * @param objA object to be extended
     * @param objB source object
     */
    private extendObj<T1,T2>(objA: T1, objB: T2) {
        for(let key in objB){
            if(objB.hasOwnProperty(key)){
                (objA as any)[key] = (objB as any)[key];
            }
        }
        return <T1&T2>objA;
    }

    /**
     * @param consumes string[] mime-types
     * @return true: consumes contains 'multipart/form-data', false: otherwise
     */
    private canConsumeForm(consumes: string[]): boolean {
        const form = 'multipart/form-data';
        for (let consume of consumes) {
            if (form === consume) {
                return true;
            }
        }
        return false;
    }

    /**
     *
     * @summary Delete service
     * @param id Unique entity identifier
     */
    public _delete(id: number, extraHttpRequestParams?: any): Observable<{}> {
        return this._deleteWithHttpInfo(id, extraHttpRequestParams)
            .map((response: Response) => {
                if (response.status === 204) {
                    return undefined;
                } else {
                    return response.json() || {};
                }
            });
    }

    /**
     *
     * @summary Create service in order
     * @param appControllersServiceCreate
     */
    public create(appControllersServiceCreate?: AppControllersServiceCreate, extraHttpRequestParams?: any): Observable<{}> {
        return this.createWithHttpInfo(appControllersServiceCreate, extraHttpRequestParams)
            .map((response: Response) => {
                if (response.status === 204) {
                    return undefined;
                } else {
                    return response.json() || {};
                }
            });
    }
    /**
     * Create service in order
     *
     * @param appControllersServiceCreate
     */
    public createWithHttpInfo
        appControllersServiceCreate?: AppControllersServiceCreate,
        extraHttpRequestParams?: any): Observable<Response> {
        const path = this.basePath + '/dedic/services';

        let queryParameters = new URLSearchParams();
         // https://github.com/angular/angular/issues/6845
        let headers = new Headers(this.defaultHeaders.toJSON());

        // to determine the Accept header
        let produces: string[] = [
            'application/json'
        ];

        // authentication (oauth) required
        // oauth required
        if (this.configuration.accessToken) {
            let accessToken = typeof this.configuration.accessToken === 'function'
                ? this.configuration.accessToken()
                : this.configuration.accessToken;
            headers.set('Authorization', 'Bearer ' + accessToken);
        }

        headers.set('Content-Type', 'application/json');

        let requestOptions: RequestOptionsArgs = new RequestOptions({
            method: RequestMethod.Post,
            headers: headers,
            // https://github.com/angular/angular/issues/10612
            body: appControllersServiceCreate == null ? '' : JSON.stringify(appControllersServiceCreate), 
            search: queryParameters,
            withCredentials:this.configuration.withCredentials
        });
        // https://github.com/swagger-api/swagger-codegen/issues/4037
        if (extraHttpRequestParams) {
            requestOptions = (<any>Object).assign(requestOptions, extraHttpRequestParams);
        }

        return this.http.request(path, requestOptions);
    }
}

Таким образом, для того, чтобы сделать, например, post-запрос на создание услуги с помощью соответствующего сервиса необходимо:


  • Добавить в компонент Service.service
  • Вызвать метод service.create с параметром в соответствии с интерфейсом appControllersServiceCreate
  • Подписаться для получения результата

Сразу хочу пояснить, почему параметр носит имя в стиле Java. Причина в том, что это имя формируется из спецификации, а точнее из поля name:


post:
    tags: [Dedicated, Service]
    x-swagger-router-controller: app.controllers.service
    operationId: create
    security:
      - oauth: []
    summary: Create service in order
    parameters:
      - name: app_controllers_service_create
        in: body

Мы решили использовать такое громоздкое название, чтобы имена не пересекались и были уникальными. Если указать в качестве имени, например, data, то codegen будет добавлять к data счетчик и это выльется в 10 интерфейсов с именем Data_0, Data_1 и так далее. Найти нужный интерфейс при импорте становится проблематично).


Также, стоит знать, что codegen создает модули, которые необходимо импортировать, а их имена формируются исходя из тега метода. Таким образом, вышеуказанный метод будет присутствовать в модулях Dedicated и Service. Это удобно, так как позволяет не импортировать api целиком и не блуждать среди методов, а использовать только то, что требовалось для компонента.


Как известно, в Angular 4.4 заменили HttpModule на HttpClientModule, который добавил удобства (почитать о разнице можно например тут. Но, к сожалению, текущая стабильная версия codegen работает с HttpModule. Поэтому остаются подобные конструкции:


HttpClientModule вернул был json по умолчанию:


.map((response: Response) => {
    if (response.status === 204) {
        return undefined;
    } else {
        return response.json() || {};
    }

Добавление заголовка для авторизации ложится на плечи HttpInterceptor:


if (this.configuration.accessToken) {
            let accessToken = typeof this.configuration.accessToken === 'function'
                ? this.configuration.accessToken()
                : this.configuration.accessToken;
            headers.set('Authorization', 'Bearer ' + accessToken);
        }

С нетерпением ждем обновления, а пока работаем с тем, что есть.


В следующей части я начну рассказ уже непосредственно про Angular и api буду касаться уже со стороны фронтенда.




К сожалению, не доступен сервер mySQL