Вступление. Еще немного про api.
Итак, в прошлый раз мы остановились на описание процесса сборки 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 к фронтенду.
Как я уже говорил, спецификация 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:
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'
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'
}
}
/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)
/* 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 буду касаться уже со стороны фронтенда.