Skip to content

Commit d9db405

Browse files
authored
feat: copy curl command support in span detail component (#2603)
* fix: copy curl command support in span detail component * fix: support to generate curl command
1 parent 7080b66 commit d9db405

File tree

7 files changed

+258
-9
lines changed

7 files changed

+258
-9
lines changed

projects/observability/src/public-api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,6 @@ export * from './shared/utils/time-range';
413413

414414
// CSV Downloader Service
415415
export * from './shared/services/global-csv-download/global-csv-download.service';
416+
417+
// Curl Command Generator
418+
export * from './shared/utils/curl-command-generator/curl-command-generator-util';

projects/observability/src/shared/components/span-detail/span-data.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ export interface SpanData {
1818
exitCallsBreakup?: Dictionary<string>;
1919
startTime?: number;
2020
logEvents?: LogEvent[];
21+
requestMethod?: string;
2122
}

projects/observability/src/shared/components/span-detail/span-detail.component.scss

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
display: flex;
1212
flex-direction: column;
1313

14-
.toggle-group {
14+
.toggle-group-and-actions {
15+
display: flex;
16+
align-items: center;
17+
gap: 8px;
1518
margin-top: 18px;
1619
}
1720

projects/observability/src/shared/components/span-detail/span-detail.component.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
22
import { IconType } from '@hypertrace/assets-library';
33
import { TypedSimpleChanges } from '@hypertrace/common';
4-
import { ToggleItem } from '@hypertrace/components';
54
import { isEmpty } from 'lodash-es';
65
import { Observable, ReplaySubject } from 'rxjs';
6+
import { ObservabilityIconType } from '../../icons/observability-icon-type';
77
import { SpanData } from './span-data';
88
import { SpanDetailLayoutStyle } from './span-detail-layout-style';
99
import { SpanDetailTab } from './span-detail-tab';
10+
import { CurlCommandGeneratorUtil } from '../../utils/curl-command-generator/curl-command-generator-util';
11+
import { ButtonSize, ToggleItem } from '@hypertrace/components';
1012

1113
@Component({
1214
selector: 'ht-span-detail',
@@ -27,13 +29,25 @@ import { SpanDetailTab } from './span-detail-tab';
2729
<div class="summary-container">
2830
<ng-content></ng-content>
2931
</div>
30-
<ht-toggle-group
31-
class="toggle-group"
32-
[activeItem]="this.activeTab$ | async"
33-
[items]="this.tabs"
34-
(activeItemChange)="this.changeTab($event)"
35-
>
36-
</ht-toggle-group>
32+
33+
<div class="toggle-group-and-actions">
34+
<ht-toggle-group
35+
class="toggle-group"
36+
[activeItem]="this.activeTab$ | async"
37+
[items]="this.tabs"
38+
(activeItemChange)="this.changeTab($event)"
39+
>
40+
</ht-toggle-group>
41+
42+
<ht-copy-to-clipboard
43+
*ngIf="this.showCurlCommand"
44+
size="${ButtonSize.Medium}"
45+
icon="${ObservabilityIconType.Api}"
46+
[text]="this.getCurlCommand | htMemoize: this.spanData"
47+
label=""
48+
tooltip="Copy curl command"
49+
></ht-copy-to-clipboard>
50+
</div>
3751
3852
<div class="tab-container" *ngIf="this.activeTab$ | async as activeTab">
3953
<ng-container [ngSwitch]="activeTab?.value">
@@ -106,6 +120,10 @@ export class SpanDetailComponent implements OnChanges {
106120

107121
@Input()
108122
public showAttributesTab: boolean = true;
123+
124+
@Input()
125+
public showCurlCommand: boolean = false;
126+
109127
@Output()
110128
public readonly closed: EventEmitter<void> = new EventEmitter<void>();
111129
public showRequestTab?: boolean;
@@ -153,6 +171,16 @@ export class SpanDetailComponent implements OnChanges {
153171
this.activeTabSubject.next(tab);
154172
}
155173

174+
protected getCurlCommand = (span: SpanData): string =>
175+
CurlCommandGeneratorUtil.generateCurlCommand(
176+
span.requestHeaders,
177+
span.requestCookies,
178+
span.requestBody,
179+
span.requestUrl,
180+
span.protocolName ?? '',
181+
span.requestMethod ?? '',
182+
);
183+
156184
/**
157185
* Tabs are added in order:
158186
* 1. Request

projects/observability/src/shared/components/span-detail/span-detail.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
22
import { NgModule } from '@angular/core';
33
import {
44
ButtonModule,
5+
CopyToClipboardModule,
56
IconModule,
67
JsonViewerModule,
78
LabelModule,
@@ -20,6 +21,7 @@ import { SpanRequestDetailModule } from './request/span-request-detail.module';
2021
import { SpanResponseDetailModule } from './response/span-response-detail.module';
2122
import { SpanDetailComponent } from './span-detail.component';
2223
import { SpanTagsDetailModule } from './tags/span-tags-detail.module';
24+
import { MemoizeModule } from '@hypertrace/common';
2325

2426
@NgModule({
2527
imports: [
@@ -41,6 +43,8 @@ import { SpanTagsDetailModule } from './tags/span-tags-detail.module';
4143
LogEventsTableModule,
4244
ToggleGroupModule,
4345
MessageDisplayModule,
46+
CopyToClipboardModule,
47+
MemoizeModule,
4448
],
4549
declarations: [SpanDetailComponent],
4650
exports: [SpanDetailComponent],
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { CurlCommandGeneratorUtil } from './curl-command-generator-util';
2+
3+
describe('generateCurlCommand', () => {
4+
test('should generate a curl command for HTTP GET requests', () => {
5+
const requestUrl = 'https://example.com';
6+
const protocol = 'HTTP';
7+
const methodType = 'GET';
8+
const requestHeaders = {};
9+
const requestCookies = {};
10+
const requestBody = '';
11+
12+
const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
13+
requestHeaders,
14+
requestCookies,
15+
requestBody,
16+
requestUrl,
17+
protocol,
18+
methodType,
19+
);
20+
21+
expect(curlCommand).toEqual(`curl -X GET https://example.com`);
22+
});
23+
24+
test('should generate a curl command for HTTP POST requests with headers and body', () => {
25+
const requestUrl = 'https://example.com';
26+
const protocol = 'HTTP';
27+
const methodType = 'POST';
28+
const requestHeaders = {
29+
'Content-Type': 'application/json',
30+
Accept: 'application/json',
31+
};
32+
const requestCookies = {};
33+
const requestBody = '{"name": "John", "age": 30}';
34+
35+
const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
36+
requestHeaders,
37+
requestCookies,
38+
requestBody,
39+
requestUrl,
40+
protocol,
41+
methodType,
42+
);
43+
44+
expect(curlCommand).toEqual(
45+
`curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"name": "John", "age": 30}' https://example.com`,
46+
);
47+
});
48+
49+
test('should generate a curl command for HTTP POST requests with cookies', () => {
50+
const requestUrl = 'https://example.com';
51+
const protocol = 'HTTP';
52+
const methodType = 'POST';
53+
const requestHeaders = {};
54+
const requestCookies = {
55+
sessionid: '123456789',
56+
user: 'John Doe',
57+
};
58+
const requestBody = '{"name": "John", "age": 30}';
59+
const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
60+
requestHeaders,
61+
requestCookies,
62+
requestBody,
63+
requestUrl,
64+
protocol,
65+
methodType,
66+
);
67+
68+
expect(curlCommand).toEqual(
69+
`curl -X POST -b 'sessionid=123456789;user=John Doe' -d '{"name": "John", "age": 30}' https://example.com`,
70+
);
71+
});
72+
73+
test('should generate a curl command for HTTP POST requests with headers, cookies, and body', () => {
74+
const requestUrl = 'https://example.com';
75+
const protocol = 'HTTP';
76+
const methodType = 'POST';
77+
const requestHeaders = {
78+
'Content-Type': 'application/json',
79+
Accept: 'application/json',
80+
};
81+
const requestCookies = {
82+
sessionid: '123456789',
83+
user: 'John Doe',
84+
};
85+
const requestBody = '{"name": "John", "age": 30}';
86+
87+
const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
88+
requestHeaders,
89+
requestCookies,
90+
requestBody,
91+
requestUrl,
92+
protocol,
93+
methodType,
94+
);
95+
96+
expect(curlCommand).toEqual(
97+
`curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -b 'sessionid=123456789;user=John Doe' -d '{"name": "John", "age": 30}' https://example.com`,
98+
);
99+
});
100+
101+
test('should return an error message for unsupported protocols', () => {
102+
const requestUrl = 'https://example.com';
103+
const protocol = 'ftp';
104+
const methodType = 'POST';
105+
const requestHeaders = {};
106+
const requestCookies = {};
107+
const requestBody = '';
108+
109+
const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
110+
requestHeaders,
111+
requestCookies,
112+
requestBody,
113+
requestUrl,
114+
protocol,
115+
methodType,
116+
);
117+
118+
expect(curlCommand).toEqual('curl command is not supported');
119+
});
120+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Dictionary } from '@hypertrace/common';
2+
3+
export abstract class CurlCommandGeneratorUtil {
4+
private static readonly CURL_COMMAND_NAME: string = 'curl';
5+
private static readonly HEADER_OPTION: string = '-H';
6+
private static readonly REQUEST_OPTION: string = '-X';
7+
private static readonly BODY_OPTION: string = '-d';
8+
private static readonly COOKIES_OPTION: string = '-b';
9+
private static readonly SINGLE_SPACE: string = ' ';
10+
private static readonly SINGLE_QUOTES_DELIMITER_CHAR: string = "\\'";
11+
private static readonly SINGLE_QUOTE_CHAR: string = "'";
12+
private static readonly SEMI_COLON_CHAR: string = ';';
13+
private static readonly COLON_CHAR: string = ':';
14+
private static readonly EQUALS_CHAR: string = '=';
15+
private static readonly GET_METHOD: string = 'GET';
16+
private static readonly DELETE_METHOD: string = 'DELETE';
17+
private static readonly NOT_SUPPORTED_MESSAGE: string = 'curl command is not supported';
18+
19+
public static generateCurlCommand(
20+
requestHeaders: Dictionary<unknown>,
21+
requestCookies: Dictionary<unknown>,
22+
requestBody: string,
23+
requestUrl: string,
24+
protocol: string,
25+
methodType: string,
26+
): string {
27+
let curlCommand: string = '';
28+
29+
if (protocol === Protocol.PROTOCOL_HTTP || protocol === Protocol.PROTOCOL_HTTPS) {
30+
curlCommand += `${this.CURL_COMMAND_NAME}${this.SINGLE_SPACE}${this.REQUEST_OPTION}${this.SINGLE_SPACE}${methodType}${this.SINGLE_SPACE}`;
31+
32+
if (Object.entries(requestHeaders).length > 0) {
33+
curlCommand += `${this.getHeadersAsString(requestHeaders)}`;
34+
}
35+
36+
if (Object.entries(requestCookies).length > 0) {
37+
curlCommand += `${this.getCookiesAsString(requestCookies)}`;
38+
}
39+
40+
// { POST, PUT, PATCH } methodType will have a body, and { GET, DELETE } will not.
41+
if (!(methodType.includes(this.GET_METHOD) || methodType.includes(this.DELETE_METHOD))) {
42+
curlCommand += `${this.BODY_OPTION}${this.SINGLE_SPACE}${this.SINGLE_QUOTE_CHAR}${this.getEnrichedBody(
43+
requestBody,
44+
)}${this.SINGLE_QUOTE_CHAR}${this.SINGLE_SPACE}`;
45+
}
46+
47+
curlCommand += requestUrl;
48+
} else {
49+
curlCommand += this.NOT_SUPPORTED_MESSAGE;
50+
}
51+
52+
return curlCommand;
53+
}
54+
55+
private static getHeadersAsString(requestHeaders: Dictionary<unknown>): string {
56+
return Object.entries(requestHeaders)
57+
.map(
58+
([key, value]) =>
59+
`${this.HEADER_OPTION}${this.SINGLE_SPACE}${this.SINGLE_QUOTE_CHAR}${key}${this.COLON_CHAR}${this.SINGLE_SPACE}${value}${this.SINGLE_QUOTE_CHAR}${this.SINGLE_SPACE}`,
60+
)
61+
.join('');
62+
}
63+
64+
private static getCookiesAsString(requestCookies: Dictionary<unknown>): string {
65+
let cookiesString: string = '';
66+
67+
cookiesString += `${this.COOKIES_OPTION}${this.SINGLE_SPACE}${this.SINGLE_QUOTE_CHAR}`;
68+
69+
Object.entries(requestCookies).forEach(([key, value]) => {
70+
cookiesString += `${key}${this.EQUALS_CHAR}${value}${this.SEMI_COLON_CHAR}`;
71+
});
72+
73+
if (cookiesString[cookiesString.length - 1] === this.SEMI_COLON_CHAR) {
74+
cookiesString = cookiesString.substr(0, cookiesString.length - 1);
75+
}
76+
77+
cookiesString += `${this.SINGLE_QUOTE_CHAR}${this.SINGLE_SPACE}`;
78+
79+
return cookiesString;
80+
}
81+
82+
private static getEnrichedBody(body: string): string {
83+
return body.replaceAll(this.SINGLE_QUOTE_CHAR, this.SINGLE_QUOTES_DELIMITER_CHAR);
84+
}
85+
}
86+
87+
const enum Protocol {
88+
PROTOCOL_HTTP = 'HTTP',
89+
PROTOCOL_HTTPS = 'HTTPS',
90+
}

0 commit comments

Comments
 (0)