目录
那些对C#和TypeScript不友好的Swagger/OpenAPI定义
背景
作为使用基于JSON的Web API的应用程序开发人员,如果服务供应商没有提供客户端API库,而是提供OpenAPI定义文件,则您一直在使用一些 Swagger/OpenAPI工具 (https://openapi.tools/)来生成客户端API代码。
由于Web API和相应的OpenAPI定义文件可能会随着时间的推移而发展,因此需要更新客户端程序。
“强类型OpenAPI客户端生成器”(OpenApiClientGen)针对面向jQuery、Angular 2+、Aurelia、AXIOS和Fetch API的C#客户端和TypeScript客户端进行了优化。
本文是对以下文章的补充:
希望这种互补可以帮助您设计有关安全性、负载平衡、用户体验和开发人员体验的实际解决方案的干净且可维护的架构。
介绍
“强类型OpenAPI客户端生成器”(OpenApiClientGen)具有以下有意限制,并且按规范设计:
- 重点关注强类型数据模型。
- 对于API操作的 HTTP 响应,仅生成"HTTP Status Code"=200和“Content Type"="application/json”的代码。
- 跳过操作参数的HTTP标头的定义。
- 跳过身份验证和授权的定义。
本文介绍了一些针对这些有意限制的预期解决方案,以便为具有复杂数据模型和工作流的复杂业务应用程序编写干净的应用程序代码。
如果Web API或相应的Swagger/OpenAPI定义包含非常动态、通用或弱类型的数据,或者API操作仅支持text/xml、表单和二进制有效负载,则OpenApiClientGen不适合您,而有很多 Swagger/OpenAPI客户端编码工具(OpenAPI Tooling)可以处理此类场景。
与其他工具相比,OpenApiClientGen为应用程序编程提供了以下好处:
- 强类型数据尽可能匹配到服务器端数据绑定。自2020年首次发布以来,它一直支持十进制/货币/货币数据类型,而v3.1之前的Swagger/OpenAPI规范并未为此类数据类型提供固有支持。
- 生成的源代码和构建的图像的占用空间要小得多。
- 对于TypeScript程序员来说,生成的代码满足TypeScript严格模式和Angular严格模式。
- 对于TypeScript程序员,生成的HTTP客户端操作利用您一直在使用的HTTP客户端请求类:jQuery的jQuery.ajax、Angular 2+的HttpClient、Aurelia的HttpClient、AXIOS和Fetch API。
- 对于Angular程序员,该NG2FormGroup插件可以使用验证器生成严格类型的表单代码。
为了强大的错误处理、技术日志记录和用户体验,预期的限制不会影响实际应用程序所需的全面错误处理,而互联网本质上是不可靠的。
使用代码
以下TypeScript和C#代码示例旨在描述应用程序编程的概念思想以及其OpenApiClientGen设计意图如何帮助您编写干净的应用程序代码。对于具体的解决方案,您可能仍然需要进一步学习并参考其他更有经验的程序员编写的许多精心编写的教程文章。
用于身份验证
并且假定您熟悉用于身份验证的应用程序编程中的HTTP 截。
Bearer Token
在有关处理持有者令牌的本节中,Angular的扩展“英雄之旅”演示了如何集中处理客户端API代码发出的每个请求的持有者令牌。
生成的Angular TypeScript代码
@Injectable()
export class Heroes {
constructor(@Inject('baseUri') private baseUri:
string = window.location.protocol + '//' + window.location.hostname +
(window.location.port ? ':' + window.location.port : '') + '/',
private http: HttpClient) {
}
/**
* Get a hero. Nullable reference. MaybeNull
* GET api/Heroes/{id}
*/
getHero(id?: number | null, headersHandler?: () =>
HttpHeaders): Observable<DemoWebApi_Controllers_Client.Hero | null> {
return this.http.get<DemoWebApi_Controllers_Client.Hero | null>
(this.baseUri + 'api/Heroes/' + id,
{ headers: headersHandler ? headersHandler() : undefined });
}
/**
* POST api/Heroes
*/
post(name?: string | null, headersHandler?: () => HttpHeaders):
Observable<DemoWebApi_Controllers_Client.Hero> {
return this.http.post<DemoWebApi_Controllers_Client.Hero>
(this.baseUri + 'api/Heroes', JSON.stringify(name),
{ headers: headersHandler ? headersHandler().append
('Content-Type', 'application/json;charset=UTF-8') :
new HttpHeaders({ 'Content-Type':
'application/json;charset=UTF-8' }) });
}
...
/**
* Complex hero type
*/
export interface Hero {
address?: DemoWebApi_DemoData_Client.Address;
death?: Date | null;
dob?: Date | null;
emailAddress?: string | null;
id?: number | null;
/**
* Required
* String length: inclusive between 2 and 120
*/
name?: string | null;
phoneNumbers?: Array<DemoWebApi_DemoData_Client.PhoneNumber>;
/** Min length: 6 */
webAddress?: string | null;
}
/**
* Complex hero type
*/
export interface HeroFormProperties {
death: FormControl<Date | null | undefined>,
dob: FormControl<Date | null | undefined>,
emailAddress: FormControl<string | null | undefined>,
id: FormControl<number | null | undefined>,
/**
* Required
* String length: inclusive between 2 and 120
*/
name: FormControl<string | null | undefined>,
/** Min length: 6 */
webAddress: FormControl<string | null | undefined>,
}
export function CreateHeroFormGroup() {
return new FormGroup<HeroFormProperties>({
death: new FormControl<Date | null | undefined>(undefined),
dob: new FormControl<Date | null | undefined>(undefined),
emailAddress: new FormControl<string | null | undefined>
(undefined, [Validators.email]),
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>
(undefined, [Validators.required,
Validators.maxLength(120), Validators.minLength(2)]),
webAddress: new FormControl<string | null | undefined>
(undefined, [Validators.minLength(6),
Validators.pattern('https?:\\/\\/(www\\.)?
[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b
([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)')]),
});
}
HTTP拦截器
主要的JavaScript/TypeScript框架(如Angular和Aurelia)提供了内置的HTTP拦截方式,因此您可以以批发和预定义的方式创建具有更新凭据的HTTP客户端实例。尽管其他JavaScript库(如React)或框架(如VueJs)不提供内置的HTTP类或HTTP拦截,但使用AXIOS或Fetch API生成的API制作一个库应该不难,因为有许多在线教程可用。
在tokeninterceptor.ts中:
@Injectable()
export class TokenInterceptor implements HttpInterceptor {
constructor(@Inject('BACKEND_URLS') private backendUrls: string[],
@Inject('IAuthService') private authService: IAuthService) {
console.debug('TokenInterceptor created.');
}
intercept(request: HttpRequest<any>, httpHandler: HttpHandler):
Observable<HttpEvent<any>> {
var requestNeedsInterceptor = this.backendUrls.find
(d => request.url.indexOf(d) >= 0);
if (!requestNeedsInterceptor) {
return httpHandler.handle(request);
}
let refinedRequest: HttpRequest<any>;
if (AUTH_STATUSES.access_token) {
//The Request/Response objects need to be immutable.
//Therefore, we need to clone the original request before we return it.
refinedRequest = request.clone({
setHeaders: {
Authorization: `Bearer ${AUTH_STATUSES.access_token}`,
Accept: 'application/json,text/html;q=0.9,*/*;q=0.8',
}
});
} else {
refinedRequest = request.clone({
setHeaders: {
Accept: 'application/json,text/html;q=0.9,*/*;q=0.8',
}
});
}
return httpHandler.handle(refinedRequest).pipe(catchError(err => {
if ([401, 403].includes(err.status)) {
console.debug('401 403');
if (AUTH_STATUSES.refreshToken) {
return AuthFunctions.getNewAuthToken
(this.authService, refinedRequest, httpHandler);
}
}
return Promise.reject(err);
}));
}
...
通常,您将提供生成的API DemoWebApi_Controllers_Client.Heroes和在httpServices.module.ts中的TokenInterceptor:
export function clientFactory(http: HttpClient) {
return new namespaces.DemoWebApi_Controllers_Client.Heroes
(SiteConfigConstants.apiBaseuri, http);
}
@NgModule({})
export class HttpServicesModule {
static forRoot(): ModuleWithProviders<HttpServicesModule> {
return {
ngModule: HttpServicesModule,
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: TokenInterceptor,
multi: true
},
{
provide: DemoWebApi_Controllers_Client.Heroes,
useFactory: clientFactory,
deps: [HttpClient],
},
应用代码
在hero-detail.component.ts中:
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html'
})
export class HeroDetailComponent implements OnInit {
hero?: DemoWebApi_Controllers_Client.Hero;
heroForm: FormGroup<HeroWithNestedFormProperties>;
constructor(
private heroService: DemoWebApi_Controllers_Client.Heroes,
...
) {
this.heroForm = CreateHeroWithNestedFormGroup();
}
save(): void {
const raw: DemoWebApi_Controllers_Client.Hero =
this.heroForm.getRawValue(); // nested array included.
this.heroService.put(raw).subscribe(
{
next: d => {
...
},
error: error => alert(error)
}
);
}
在应用程序代码中,您看不到处理身份验证的显式代码,因为身份验证头由提供的TokenInterceptor处理。
PRODA和Medicare Online
Medicare Online是澳大利亚联邦政府于2021年为Medicare推出的一项网络服务,取代了传统的适配器方式。它使用PRODA进行基本身份验证和授权。本部分无意成为Medicare Online或PRODA的编程教程,而是启发您处理应用程序代码中复杂密钥交换和动态标头信息的类似方案。
言论
- 作为客户供应商,我花了几天时间来了解和为我的公司设置一个PRODA实例,而PRODA本身在正式发布之前就一直在不断发展。因此,不要指望自己能理解这里的每一个技术细节,而是要为自己的类似场景寻找灵感。
parameters:
Authorization:
name: Authorization
type: string
required: true
in: header
description: JWT header for authorization
default: Bearer REPLACE_THIS_KEY
dhs-auditId:
name: dhs-auditId
type: string
required: true
in: header
description: DHS Audit ID
default: LOC00001
dhs-subjectId:
name: dhs-subjectId
type: string
required: true
in: header
description: DHS Subject ID
default: '2509999891'
...
...
/mcp/bulkbillstoreforward/specialist/v1:
post:
summary: This is the request
parameters:
- name: body
required: true
in: body
schema:
$ref: '#/definitions/BulkBillStoreForwardRequestType'
responses:
"200":
description: successful operation
schema:
$ref: '#/definitions/BulkBillStoreForwardResponseType'
"400":
description: server cannot or will not process the request
schema:
$ref: '#/definitions/ServiceMessagesType'
operationId: mcp-bulk-bill-store-forward-specialist@1-eigw
parameters:
- $ref: '#/parameters/Authorization'
- $ref: '#/parameters/dhs-auditId'
- $ref: '#/parameters/dhs-subjectId'
- $ref: '#/parameters/dhs-messageId'
- $ref: '#/parameters/dhs-auditIdType'
- $ref: '#/parameters/dhs-correlationId'
- $ref: '#/parameters/dhs-productId'
- $ref: '#/parameters/dhs-subjectIdType'
public async Task<BulkBillStoreForwardResponseType> BulkBillStoreForwardSpecialistAsync
(BulkBillStoreForwardRequestType requestBody,
Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
{
var requestUri = "mcp/bulkbillstoreforward/specialist/v1";
using (var httpRequestMessage = new System.Net.Http.HttpRequestMessage
(System.Net.Http.HttpMethod.Post, requestUri))
{
using (var requestWriter = new System.IO.StringWriter())
{
var requestSerializer = JsonSerializer.Create(jsonSerializerSettings);
requestSerializer.Serialize(requestWriter, requestBody);
var content = new System.Net.Http.StringContent
(requestWriter.ToString(), System.Text.Encoding.UTF8, "application/json");
httpRequestMessage.Content = content;
if (handleHeaders != null)
{
handleHeaders(httpRequestMessage.Headers);
}
var responseMessage = await httpClient.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var responseMessageStream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(responseMessageStream)))
{
var serializer = JsonSerializer.Create(jsonSerializerSettings);
return serializer.Deserialize<BulkBillStoreForwardResponseType>(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
}
如您所见,生成的代码跳过了标头参数的定义,只处理业务数据有效负载。
应用代码
BulkBillStoreForwardRequestType requestBody;
...
try
{
var response = await NewClientWithNewIds(ids).BulkBillStoreForwardSpecialistAsync
(requestBody, (headers) =>
{
headers.TryAddWithoutValidation("dhs-subjectId", medicareNumber);
headers.TryAddWithoutValidation("dhs-subjectIdType", "Medicare Card");
});
...
}
catch (Fonlow.Net.Http.WebApiRequestException ex)
{
HandleBadRequest(ids.TraceIdentity, ex);
throw;
}
生成的API代码提供自定义标头处理程序,因此你可以根据已发布的Medicare Web API手册调整应用逻辑的HTTP标头。在这种情况下,您可以轻松提供Medicare卡号。
身份验证的批发处理
如果Web服务设计正确,则一组Web API函数应共享相同的方式,向后端提供凭据和负载平衡信息。
NewClientWithNewIds()封装用于在批发时处理身份验证信息的辅助函数和工厂。
...
override protected McpClient NewClientWithNewIds(McpTransactionIds ids)
{
var client = NewHttpClientClientWithNewIds(ids, "mcp");
return new McpClient(client, HttpClientSerializerSettings.Create());
}
override protected McpClient NewClientWithCorrelationId
(McpIdentity ids, string correlationId)
{
var client = NewHttpClientWithCorrelationId(ids, correlationId, "mcp");
return new McpClient(client, HttpClientSerializerSettings.Create());
}
protected HttpClient NewHttpClientWithCorrelationId
(McpIdentity ids, string correlationId, string httpClientName)
{
var client = httpClientFactory.CreateClient(httpClientName);
client.DefaultRequestHeaders.TryAddWithoutValidation("orgId", ids.OrgId);
client.DefaultRequestHeaders.TryAddWithoutValidation
("dhs-correlationId", correlationId);
client.DefaultRequestHeaders.TryAddWithoutValidation
("proda-clientId", ids.ProdaClientId);
return client;
}
典型的HTTP请求是这样的:
POST https://healthclaiming.api.humanservices.gov.au/claiming/ext-prd/mcp/patientverification/medicare/v1 HTTP/1.1
Host: healthclaiming.api.humanservices.gov.au
dhs-subjectId: 4253263768
dhs-subjectIdType: Medicare Card Number
Accept: application/json
orgId: 7125128193
dhs-correlationId: urn:uuid:MLK412708F2C7DDCE2598138
Authorization: Bearer eyJraWQiOiJCb...elkFglNxQ
dhs-productId: My company healthcare product 1.0
dhs-auditId: KKK41270
X-IBM-Client-Id: d58ab88eac7d983adb24ef00519faf61
dhs-auditIdType: Location Id
dhs-messageId: urn:uuid:3b39c9a6-c220-458c-bd5d-32dd47ec7738
traceparent: 00-fdc2b432189e291b7eb537c3a4777c4d-b0617b3e1b9fafcb-00
Content-Type: application/json; charset=utf-8
Content-Length: 275
{"patient":{"medicare":{"memberNumber":"4453263778","memberRefNumber":"2"},
"identity":{"dateOfBirth":"1980-10-28","familyName":"Bean","givenName":"Tom","sex":"1"},
"residentialAddress":{"locality":"Warner","postcode":"4500"}},
"dateOfService":"2022-01-23","typeCode":"PVM"}
对于非200 HTTP响应
应用代码
try
{
var response = await NewClientWithNewIds(ids).BulkBillStoreForwardSpecialistAsync
(requestBody, (headers) =>
{
headers.TryAddWithoutValidation("dhs-subjectId", medicareNumber);
headers.TryAddWithoutValidation("dhs-subjectIdType", "Medicare Card");
});
...
}
catch (Fonlow.Net.Http.WebApiRequestException ex)
{
HandleBadRequest(ids.TraceIdentity, ex);
throw;
}
对于非2xx HTTP响应,生成的客户端API函数可能会抛出System.Net.Http.HttpRequestException或Fonlow.Net.Http.WebApiRequestException公开更多响应信息以进行详细的错误处理。
兴趣点
虽然集成OpenApiClientGen测试套件已经涵盖了2,000个Swagger/OpenAPI定义文件和5,000多个测试用例,但我只检查了几十个包含身份验证元的定义文件。我怀疑Swagger/OpenAPI v2、v3、vX是否可能涵盖各种“标准”身份验证机制,而忽略了服务端每个实现的特殊行为,因此我怀疑代码生成器是否可以生成可以直接用于应用程序编程的实用身份验证代码。最好让应用程序程序员编写身份验证代码,只要整体设计使应用程序代码中的每个客户端API调用看起来简单干净。
那些对C#和TypeScript不友好的Swagger/OpenAPI定义
至少有几十种流行的编程语言用于开发Web API。后端程序员可能已经声明服务端的标识符对客户端的C#和TypeScript不友好:
- 与C#和TypeScript的保留字发生冲突或冲突
- enum通用的文字字符串
- 名称不适合类型名称、参数和函数名称等。
- ...
OpenApiClientGen重命名这些标识符,而不会影响请求负载和类型化响应的处理。
提示
- 处理enum相当棘手。默认情况下,ASP.NET(Core)Web API可以处理enum为string或整数。如果确定Web API仅期望enum为string,只需打开选项“EnumToString”即可。
- 有些yaml文件可能有多个operationId不唯一,或者operationId+tags不唯一的组合,当OpenApiClientGen默认情况下,使用operationId+tags作为客户端 API 函数名称时,可以使用选项“ActionNameStrategy.PathMethodQueryParameters”。
非“application/json”有效负载
目前,OpenApiClientGen v3仍会生成客户端API代码,但显然客户端函数无法正确与后端通信,因此需要注意不要在应用代码中使用这些客户端函数。在v3.1中,将有一个用于跳过为此类操作生成代码的开关。
Doggy Swagger/OpenAPI定义
客户端应用程序的质量取决于Web API的质量和生成的代码的质量。为了确保生成代码的基本质量,集成测试套件涵盖了1,700多个OpenAPI v3定义和超过615个Swagger v2定义:生成代码、使用C#编译器和TypeScript/Angular编译器进行编译。
但是,我遇到了100多个doggy swagger/OpenAPI定义,其中一些来自一些著名的Web服务供应商。某些YAML文件中典型的肮脏或狗狗设计:
- 在继承的数据模型中,属性被多次声明。C#编译器或TypeScript代码分析可能会引发警告。但是,您了解拥有这种继承的恶劣影响。对于不支持继承的Angular Reactive Forms,这将导致重复的Form控件和错误。
- 错误的数据约束。例如,键入默认值为“false”; 使用maximum而不是maxItems来限制数组的大小。
- ...
如果您碰巧正在使用这些Web服务,则可以使用两种方法:
- 使用其他Swagger/OpenAPI工具,这些工具可以容忍肮脏或狗狗的设计。
- 与服务供应商联系。
言论
- 显然,成功编译生成的代码是远远不够的。编写一个集成测试套件以确保生成的代码能够正确地与后端通信非常重要。
生成代码的集成测试套件
除了为应用程序代码编写集成测试套件外,还应该在Web API上编写集成测试套件,使用生成的客户端API代码并用于以下目的:
- 了解并学习Web API的行为和质量。
- 在开发和生产过程中收到错误报告时,隔离问题区域。
https://www.codeproject.com/Articles/5376030/Intended-Solutions-for-Intentional-Limitations-of