From bca7d2bcbc6355c4357b78368768451efe6e8f84 Mon Sep 17 00:00:00 2001 From: sam0r040 <93372330+sam0r040@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:00:48 +0100 Subject: [PATCH] Feature/add message binding to channel documentation (#35) * feat: Add new tab with message binding to channel documentation and add header and message binding example to example tab * Add message binding to the request made by PublisherService so that the backend can use the message binding to construct a message * Improve mapping of message bindings to be more resilient if a backend creates a specification without message binding * Rename Binding in example tab to Message Binding and add full payload to log statement when producing messages * Display raw message bindings Regression: Previously included mapped ui attributes chore: Improve [protocol: string]: any typings Co-authored-by: david.mueller@codecentric.de --------- Co-authored-by: Timon Back --- .../channel-main/channel-main.component.css | 4 + .../channel-main/channel-main.component.html | 60 ++++++--- .../channel-main/channel-main.component.ts | 58 +++++++-- src/app/shared/asyncapi-mapper.service.ts | 123 ++++++++++++------ .../shared/components/json/json.component.ts | 4 +- .../mock/mock.springwolf-kafka-example.json | 42 ++++++ src/app/shared/models/channel.model.ts | 14 +- src/app/shared/models/example.model.ts | 11 +- src/app/shared/publisher.service.ts | 12 +- 9 files changed, 245 insertions(+), 83 deletions(-) diff --git a/src/app/channels/channel-main/channel-main.component.css b/src/app/channels/channel-main/channel-main.component.css index 4d904c5..a0874fe 100644 --- a/src/app/channels/channel-main/channel-main.component.css +++ b/src/app/channels/channel-main/channel-main.component.css @@ -33,3 +33,7 @@ button { padding: 6px; font-weight: normal; } + +[hidden] { + display: none !important; +} diff --git a/src/app/channels/channel-main/channel-main.component.html b/src/app/channels/channel-main/channel-main.component.html index f2a94bd..75c0d41 100644 --- a/src/app/channels/channel-main/channel-main.component.html +++ b/src/app/channels/channel-main/channel-main.component.html @@ -7,21 +7,51 @@

{{ operation.message.description }}

- +
+

Message Binding

+ +
+
+ +

Header

+ + +
+
+

Message

+ +
- - +
@@ -43,16 +73,14 @@

- +
- + + + +
diff --git a/src/app/channels/channel-main/channel-main.component.ts b/src/app/channels/channel-main/channel-main.component.ts index 02189d1..d8facb5 100644 --- a/src/app/channels/channel-main/channel-main.component.ts +++ b/src/app/channels/channel-main/channel-main.component.ts @@ -4,7 +4,7 @@ import { Example } from 'src/app/shared/models/example.model'; import { Schema } from 'src/app/shared/models/schema.model'; import { PublisherService } from 'src/app/shared/publisher.service'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { Operation } from 'src/app/shared/models/channel.model'; +import {MessageBinding, Operation} from 'src/app/shared/models/channel.model'; import { STATUS } from 'angular-in-memory-web-api'; @Component({ @@ -27,6 +27,8 @@ export class ChannelMainComponent implements OnInit { headersExample: Example; headersTextAreaLineCount: number; protocolName: string; + messageBindingExample?: Example; + messageBindingExampleTextAreaLineCount: number; constructor( private asyncApiService: AsyncApiService, @@ -38,47 +40,83 @@ export class ChannelMainComponent implements OnInit { ngOnInit(): void { this.asyncApiService.getAsyncApi().subscribe( asyncapi => { - let schemas: Map = asyncapi.components.schemas; - this.schemaName = this.operation.message.payload.name.slice(this.operation.message.payload.name.lastIndexOf('/') + 1) + const schemas: Map = asyncapi.components.schemas; + this.schemaName = this.operation.message.payload.name.slice(this.operation.message.payload.name.lastIndexOf('/') + 1); this.schema = schemas.get(this.schemaName); this.defaultExample = this.schema.example; this.exampleTextAreaLineCount = this.defaultExample?.lineCount || 0; - this.headersSchemaName = this.operation.message.headers.name.slice(this.operation.message.headers.name.lastIndexOf('/') + 1) + this.headersSchemaName = this.operation.message.headers.name.slice(this.operation.message.headers.name.lastIndexOf('/') + 1); this.headers = schemas.get(this.headersSchemaName); this.headersExample = this.headers.example; this.headersTextAreaLineCount = this.headersExample?.lineCount || 0; + this.messageBindingExampleTextAreaLineCount = this.messageBindingExample?.lineCount || 0; } ); this.protocolName = Object.keys(this.operation.bindings)[0]; } + isEmptyObject(object?: any): boolean { + return (object === undefined || object === null) || Object.keys(object).length === 0; + } + + createMessageBindingExample(messageBinding?: MessageBinding): Example | undefined { + if (messageBinding === undefined || messageBinding === null) { + return undefined; + } + + const bindingExampleObject = {}; + Object.keys(messageBinding).forEach((bindingKey) => { + if (bindingKey !== 'bindingVersion') { + bindingExampleObject[bindingKey] = this.getExampleValue(messageBinding[bindingKey]); + } + }); + + const bindingExample = new Example(bindingExampleObject); + + this.messageBindingExampleTextAreaLineCount = bindingExample.lineCount; + + return bindingExample; + } + + getExampleValue(bindingValue: string | Schema): any { + if (typeof bindingValue === 'string') { + return bindingValue; + } else { + return bindingValue.example.value; + } + } + recalculateLineCount(field: string, text: string): void { switch (field) { case 'example': this.exampleTextAreaLineCount = text.split('\n').length; break; case 'headers': - this.headersTextAreaLineCount = text.split('\n').length + this.headersTextAreaLineCount = text.split('\n').length; + break; + case 'massageBindingExample': + this.messageBindingExampleTextAreaLineCount = text.split('\n').length; break; } } - publish(example: string, headers: string): void { + publish(example: string, headers?: string, bindings?: string): void { try { const payloadJson = JSON.parse(example); - const headersJson = JSON.parse(headers) + const headersJson = JSON.parse(headers); + const bindingsJson = JSON.parse(bindings); - this.publisherService.publish(this.protocolName, this.channelName, payloadJson, headersJson).subscribe( + this.publisherService.publish(this.protocolName, this.channelName, payloadJson, headersJson, bindingsJson).subscribe( _ => this.handlePublishSuccess(), err => this.handlePublishError(err) ); - } catch(error) { + } catch (error) { this.snackBar.open('Example payload is not valid', 'ERROR', { duration: 3000 - }) + }); } } diff --git a/src/app/shared/asyncapi-mapper.service.ts b/src/app/shared/asyncapi-mapper.service.ts index 6a6aa8c..0b06251 100644 --- a/src/app/shared/asyncapi-mapper.service.ts +++ b/src/app/shared/asyncapi-mapper.service.ts @@ -1,10 +1,10 @@ -import { AsyncApi } from './models/asyncapi.model'; -import { Server } from './models/server.model'; -import {Channel, CHANNEL_ANCHOR_PREFIX, Message, Operation, OperationType} from './models/channel.model'; -import { Schema } from './models/schema.model'; -import { Injectable } from '@angular/core'; -import {Example} from "./models/example.model"; -import {Info} from "./models/info.model"; +import {AsyncApi} from './models/asyncapi.model'; +import {Server} from './models/server.model'; +import {Channel, CHANNEL_ANCHOR_PREFIX, Message, MessageBinding, Operation, OperationType} from './models/channel.model'; +import {Schema} from './models/schema.model'; +import {Injectable} from '@angular/core'; +import {Example} from './models/example.model'; +import {Info} from './models/info.model'; interface ServerAsyncApiSchema { description?: string; @@ -26,6 +26,10 @@ interface ServerAsyncApiMessage { description?: string; payload: { $ref: string }; headers: { $ref: string }; + bindings: {[protocol: string]: ServerAsyncApiMessageBinding}; +} +interface ServerAsyncApiMessageBinding { + [protocol: string]: ServerAsyncApiSchema | string; } interface ServerAsyncApiInfo { @@ -39,7 +43,7 @@ export interface ServerAsyncApi { asyncapi: string; info: ServerAsyncApiInfo; servers: { - [key: string]: { + [server: string]: { url: string; protocol: string; }; @@ -49,11 +53,11 @@ export interface ServerAsyncApi { description?: string; subscribe?: { message: ServerAsyncApiChannelMessage; - bindings?: any; + bindings?: {[protocol: string]: object}; }; publish?: { message: ServerAsyncApiChannelMessage; - bindings?: any; + bindings?: {[protocol: string]: object}; }; }; }; @@ -64,7 +68,7 @@ export interface ServerAsyncApi { @Injectable() export class AsyncApiMapperService { - static BASE_URL = window.location.pathname + window.location.search + "#"; + static BASE_URL = window.location.pathname + window.location.search + '#'; constructor() { } @@ -89,49 +93,52 @@ export class AsyncApiMapperService { }; } - private mapServers(servers: ServerAsyncApi["servers"]): Map { + private mapServers(servers: ServerAsyncApi['servers']): Map { const s = new Map(); Object.entries(servers).forEach(([k, v]) => s.set(k, v)); return s; } - private mapChannels(channels: ServerAsyncApi["channels"]): Channel[] { + private mapChannels(channels: ServerAsyncApi['channels']): Channel[] { const s = new Array(); Object.entries(channels).forEach(([k, v]) => { - const subscriberChannels = this.mapChannel(k, v.description, v.subscribe, "subscribe") - subscriberChannels.forEach(channel => s.push(channel)) + const subscriberChannels = this.mapChannel(k, v.description, v.subscribe, 'subscribe'); + subscriberChannels.forEach(channel => s.push(channel)); - const publisherChannels = this.mapChannel(k, v.description, v.publish, "publish") - publisherChannels.forEach(channel => s.push(channel)) + const publisherChannels = this.mapChannel(k, v.description, v.publish, 'publish'); + publisherChannels.forEach(channel => s.push(channel)); }); return s; } private mapChannel( topicName: string, - description: ServerAsyncApi["channels"][""]["description"], - serverOperation: ServerAsyncApi["channels"][""]["subscribe"] | ServerAsyncApi["channels"][""]["publish"], - operationType: OperationType): Channel[] + description: ServerAsyncApi['channels']['']['description'], + serverOperation: ServerAsyncApi['channels']['']['subscribe'] | ServerAsyncApi['channels']['']['publish'], + operationType: OperationType + ): Channel[] { - if(serverOperation !== undefined) { - let messages: Message[] = this.mapMessages(serverOperation.message) + if (serverOperation !== undefined) { + const messages: Message[] = this.mapMessages(serverOperation.message); return messages.map(message => { - const operation = this.mapOperation(operationType, message, serverOperation.bindings) + const operation = this.mapOperation(operationType, message, serverOperation.bindings); return { name: topicName, - anchorIdentifier: CHANNEL_ANCHOR_PREFIX + [operation.protocol, topicName, operation.operation, operation.message.title].join( "-"), - description: description, - operation: operation, - } - }) + anchorIdentifier: CHANNEL_ANCHOR_PREFIX + [ + operation.protocol, topicName, operation.operation, operation.message.title + ].join( '-'), + description, + operation, + }; + }); } return []; } private mapMessages(message: ServerAsyncApiChannelMessage): Message[] { - if('oneOf' in message) { - return this.mapServerAsyncApiMessages(message.oneOf) + if ('oneOf' in message) { + return this.mapServerAsyncApiMessages(message.oneOf); } return this.mapServerAsyncApiMessages([message]); } @@ -144,26 +151,56 @@ export class AsyncApiMapperService { description: v.description, payload: { name: v.payload.$ref, - anchorUrl: AsyncApiMapperService.BASE_URL +v.payload.$ref?.split('/')?.pop() + anchorUrl: AsyncApiMapperService.BASE_URL + v.payload.$ref?.split('/')?.pop() }, headers: { name: v.headers.$ref, anchorUrl: AsyncApiMapperService.BASE_URL + v.headers.$ref?.split('/')?.pop() - } - } - }) + }, + bindings: this.mapServerAsyncApiMessageBindings(v.bindings), + rawBindings: v.bindings, + }; + }); } - private mapOperation(operationType: OperationType, message: Message, bindings?: any): Operation { + private mapServerAsyncApiMessageBindings( + serverMessageBindings?: { [protocol: string]: ServerAsyncApiMessageBinding } + ): Map { + const messageBindings = new Map(); + if (serverMessageBindings !== undefined) { + Object.keys(serverMessageBindings).forEach((protocol) => { + messageBindings.set(protocol, this.mapServerAsyncApiMessageBinding(serverMessageBindings[protocol])); + }); + } + return messageBindings; + } + + private mapServerAsyncApiMessageBinding(serverMessageBinding: ServerAsyncApiMessageBinding): MessageBinding { + const messageBinding: MessageBinding = {}; + + Object.keys(serverMessageBinding).forEach((key) => { + const value = serverMessageBinding[key]; + if (typeof value === 'object') { + messageBinding[key] = this.mapSchema('MessageBinding', value); + } else { + messageBinding[key] = value; + } + }); + + return messageBinding; + } + + + private mapOperation(operationType: OperationType, message: Message, bindings?: {[protocol: string]: object}): Operation { return { protocol: this.getProtocol(bindings), operation: operationType, - message: message, - bindings: bindings - } + message, + bindings + }; } - private getProtocol(bindings?: any): string { + private getProtocol(bindings?: {[protocol: string]: object}): string { return Object.keys(bindings)[0]; } @@ -184,12 +221,12 @@ export class AsyncApiMapperService { anchorIdentifier: '#' + schemaName, anchorUrl: anchorUrl, type: schema.type, - items: items, + items, format: schema.format, enum: schema.enum, - properties: properties, + properties, required: schema.required, - example: example, - } + example, + }; } } diff --git a/src/app/shared/components/json/json.component.ts b/src/app/shared/components/json/json.component.ts index e587d00..3fd595d 100644 --- a/src/app/shared/components/json/json.component.ts +++ b/src/app/shared/components/json/json.component.ts @@ -12,10 +12,10 @@ import { Component, OnInit, Input } from '@angular/core'; export class JsonComponent implements OnInit { @Input() data: any; - json: string; + @Input() json: string; ngOnInit(): void { - this.json = JSON.stringify(this.data, null, 2); + this.json = this.json === undefined ? JSON.stringify(this.data, null, 2) : this.json; } } diff --git a/src/app/shared/mock/mock.springwolf-kafka-example.json b/src/app/shared/mock/mock.springwolf-kafka-example.json index 24df8d7..03d6347 100644 --- a/src/app/shared/mock/mock.springwolf-kafka-example.json +++ b/src/app/shared/mock/mock.springwolf-kafka-example.json @@ -40,6 +40,9 @@ }, "headers" : { "$ref" : "#/components/schemas/HeadersNotDocumented" + }, + "bindings" : { + "kafka" : { } } } }, @@ -63,6 +66,9 @@ }, "headers" : { "$ref" : "#/components/schemas/HeadersNotDocumented" + }, + "bindings" : { + "kafka" : { } } } }, @@ -87,6 +93,18 @@ }, "headers" : { "$ref" : "#/components/schemas/CloudEventHeadersForAnotherPayloadDtoEndpoint" + }, + "bindings" : { + "kafka" : { + "key" : { + "type" : "string", + "description" : "Kafka Producer Message Key", + "example" : "example-key", + "exampleSetFlag" : true, + "types" : [ "string" ] + }, + "bindingVersion" : "1" + } } }, { "name" : "io.github.stavshamir.springwolf.example.dtos.ExamplePayloadDto", @@ -96,6 +114,18 @@ }, "headers" : { "$ref" : "#/components/schemas/SpringDefaultHeaders" + }, + "bindings" : { + "kafka" : { + "key" : { + "type" : "string", + "description" : "Kafka Producer Message Key", + "example" : "example-key", + "exampleSetFlag" : true, + "types" : [ "string" ] + }, + "bindingVersion" : "1" + } } } ] } @@ -119,6 +149,9 @@ }, "headers" : { "$ref" : "#/components/schemas/HeadersNotDocumented" + }, + "bindings" : { + "kafka" : { } } } }, @@ -142,6 +175,9 @@ }, "headers" : { "$ref" : "#/components/schemas/SpringDefaultHeaders-AnotherPayloadDto" + }, + "bindings" : { + "kafka" : { } } }, { "name" : "io.github.stavshamir.springwolf.example.dtos.ExamplePayloadDto", @@ -151,6 +187,9 @@ }, "headers" : { "$ref" : "#/components/schemas/SpringDefaultHeaders-ExamplePayloadDto" + }, + "bindings" : { + "kafka" : { } } }, { "name" : "javax.money.MonetaryAmount", @@ -160,6 +199,9 @@ }, "headers" : { "$ref" : "#/components/schemas/SpringDefaultHeaders-MonetaryAmount" + }, + "bindings" : { + "kafka" : { } } } ] } diff --git a/src/app/shared/models/channel.model.ts b/src/app/shared/models/channel.model.ts index f4d6aea..b88ad03 100644 --- a/src/app/shared/models/channel.model.ts +++ b/src/app/shared/models/channel.model.ts @@ -1,4 +1,6 @@ -export const CHANNEL_ANCHOR_PREFIX = "#channel-" +import {Schema} from './schema.model'; + +export const CHANNEL_ANCHOR_PREFIX = '#channel-'; export interface Channel { name: string; anchorIdentifier: string; @@ -6,10 +8,10 @@ export interface Channel { operation: Operation; } -export type OperationType = "publish" | "subscribe"; +export type OperationType = 'publish' | 'subscribe'; export interface Operation { message: Message; - bindings?: { [type: string]: any }; + bindings?: { [protocol: string]: any }; protocol: string; operation: OperationType; } @@ -26,4 +28,10 @@ export interface Message { name: string anchorUrl: string; }; + bindings?: Map; + rawBindings?: {[protocol: string]: object}; +} + +export interface MessageBinding { + [protocol: string]: string | Schema; } diff --git a/src/app/shared/models/example.model.ts b/src/app/shared/models/example.model.ts index f60b3e1..76c2f3f 100644 --- a/src/app/shared/models/example.model.ts +++ b/src/app/shared/models/example.model.ts @@ -3,9 +3,14 @@ export class Example { public value: string; public lineCount: number; - constructor(exampleObject: object) { - this.value = JSON.stringify(exampleObject, null, 2); + constructor(exampleObject: object | string) { + if (typeof exampleObject === 'string') { + this.value = exampleObject; + } else { + this.value = JSON.stringify(exampleObject, null, 2); + } + this.lineCount = this.value.split('\n').length; } -} \ No newline at end of file +} diff --git a/src/app/shared/publisher.service.ts b/src/app/shared/publisher.service.ts index 436dea9..8c82462 100644 --- a/src/app/shared/publisher.service.ts +++ b/src/app/shared/publisher.service.ts @@ -1,18 +1,18 @@ -import { Injectable } from '@angular/core'; +import {Injectable} from '@angular/core'; import {HttpClient, HttpParams} from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { Endpoints } from './endpoints'; +import {Observable} from 'rxjs'; +import {Endpoints} from './endpoints'; @Injectable() export class PublisherService { constructor(private http: HttpClient) { } - publish(protocol: string, topic: string, payload: object, headers: object): Observable { + publish(protocol: string, topic: string, payload: object, headers: object, bindings: object): Observable { const url = Endpoints.getPublishEndpoint(protocol); const params = new HttpParams().set('topic', topic); - const body = {"payload" : payload, "headers" : headers } - console.log(`Publishing to ${url}`); + const body = {payload, headers, bindings}; + console.log(`Publishing to ${url} with messageBinding ${bindings} and headers ${headers}: ${body}`); return this.http.post(url, body, { params }); }