Skip to content

feat: copy curl command support in span detail component #2603

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jan 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions projects/observability/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,6 @@ export * from './shared/utils/time-range';

// CSV Downloader Service
export * from './shared/services/global-csv-download/global-csv-download.service';

// Curl Command Generator
export * from './shared/utils/curl-command-generator/curl-command-generator-util';
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ export interface SpanData {
exitCallsBreakup?: Dictionary<string>;
startTime?: number;
logEvents?: LogEvent[];
requestMethod?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
display: flex;
flex-direction: column;

.toggle-group {
.toggle-group-and-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 18px;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { IconType } from '@hypertrace/assets-library';
import { TypedSimpleChanges } from '@hypertrace/common';
import { ToggleItem } from '@hypertrace/components';
import { isEmpty } from 'lodash-es';
import { Observable, ReplaySubject } from 'rxjs';
import { ObservabilityIconType } from '../../icons/observability-icon-type';
import { SpanData } from './span-data';
import { SpanDetailLayoutStyle } from './span-detail-layout-style';
import { SpanDetailTab } from './span-detail-tab';
import { CurlCommandGeneratorUtil } from '../../utils/curl-command-generator/curl-command-generator-util';
import { ButtonSize, ToggleItem } from '@hypertrace/components';

@Component({
selector: 'ht-span-detail',
Expand All @@ -27,13 +29,25 @@ import { SpanDetailTab } from './span-detail-tab';
<div class="summary-container">
<ng-content></ng-content>
</div>
<ht-toggle-group
class="toggle-group"
[activeItem]="this.activeTab$ | async"
[items]="this.tabs"
(activeItemChange)="this.changeTab($event)"
>
</ht-toggle-group>

<div class="toggle-group-and-actions">
<ht-toggle-group
class="toggle-group"
[activeItem]="this.activeTab$ | async"
[items]="this.tabs"
(activeItemChange)="this.changeTab($event)"
>
</ht-toggle-group>

<ht-copy-to-clipboard
*ngIf="this.showCurlCommand"
size="${ButtonSize.Medium}"
icon="${ObservabilityIconType.Api}"
[text]="this.getCurlCommand | htMemoize: this.spanData"
label=""
tooltip="Copy curl command"
></ht-copy-to-clipboard>
</div>

<div class="tab-container" *ngIf="this.activeTab$ | async as activeTab">
<ng-container [ngSwitch]="activeTab?.value">
Expand Down Expand Up @@ -106,6 +120,10 @@ export class SpanDetailComponent implements OnChanges {

@Input()
public showAttributesTab: boolean = true;

@Input()
public showCurlCommand: boolean = false;

@Output()
public readonly closed: EventEmitter<void> = new EventEmitter<void>();
public showRequestTab?: boolean;
Expand Down Expand Up @@ -153,6 +171,16 @@ export class SpanDetailComponent implements OnChanges {
this.activeTabSubject.next(tab);
}

protected getCurlCommand = (span: SpanData): string =>
CurlCommandGeneratorUtil.generateCurlCommand(
span.requestHeaders,
span.requestCookies,
span.requestBody,
span.requestUrl,
span.protocolName ?? '',
span.requestMethod ?? '',
);

/**
* Tabs are added in order:
* 1. Request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import {
ButtonModule,
CopyToClipboardModule,
IconModule,
JsonViewerModule,
LabelModule,
Expand All @@ -20,6 +21,7 @@ import { SpanRequestDetailModule } from './request/span-request-detail.module';
import { SpanResponseDetailModule } from './response/span-response-detail.module';
import { SpanDetailComponent } from './span-detail.component';
import { SpanTagsDetailModule } from './tags/span-tags-detail.module';
import { MemoizeModule } from '@hypertrace/common';

@NgModule({
imports: [
Expand All @@ -41,6 +43,8 @@ import { SpanTagsDetailModule } from './tags/span-tags-detail.module';
LogEventsTableModule,
ToggleGroupModule,
MessageDisplayModule,
CopyToClipboardModule,
MemoizeModule,
],
declarations: [SpanDetailComponent],
exports: [SpanDetailComponent],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { CurlCommandGeneratorUtil } from './curl-command-generator-util';

describe('generateCurlCommand', () => {
test('should generate a curl command for HTTP GET requests', () => {
const requestUrl = 'https://example.com';
const protocol = 'HTTP';
const methodType = 'GET';
const requestHeaders = {};
const requestCookies = {};
const requestBody = '';

const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
requestHeaders,
requestCookies,
requestBody,
requestUrl,
protocol,
methodType,
);

expect(curlCommand).toEqual(`curl -X GET https://example.com`);
});

test('should generate a curl command for HTTP POST requests with headers and body', () => {
const requestUrl = 'https://example.com';
const protocol = 'HTTP';
const methodType = 'POST';
const requestHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
const requestCookies = {};
const requestBody = '{"name": "John", "age": 30}';

const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
requestHeaders,
requestCookies,
requestBody,
requestUrl,
protocol,
methodType,
);

expect(curlCommand).toEqual(
`curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"name": "John", "age": 30}' https://example.com`,
);
});

test('should generate a curl command for HTTP POST requests with cookies', () => {
const requestUrl = 'https://example.com';
const protocol = 'HTTP';
const methodType = 'POST';
const requestHeaders = {};
const requestCookies = {
sessionid: '123456789',
user: 'John Doe',
};
const requestBody = '{"name": "John", "age": 30}';
const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
requestHeaders,
requestCookies,
requestBody,
requestUrl,
protocol,
methodType,
);

expect(curlCommand).toEqual(
`curl -X POST -b 'sessionid=123456789;user=John Doe' -d '{"name": "John", "age": 30}' https://example.com`,
);
});

test('should generate a curl command for HTTP POST requests with headers, cookies, and body', () => {
const requestUrl = 'https://example.com';
const protocol = 'HTTP';
const methodType = 'POST';
const requestHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
const requestCookies = {
sessionid: '123456789',
user: 'John Doe',
};
const requestBody = '{"name": "John", "age": 30}';

const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
requestHeaders,
requestCookies,
requestBody,
requestUrl,
protocol,
methodType,
);

expect(curlCommand).toEqual(
`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`,
);
});

test('should return an error message for unsupported protocols', () => {
const requestUrl = 'https://example.com';
const protocol = 'ftp';
const methodType = 'POST';
const requestHeaders = {};
const requestCookies = {};
const requestBody = '';

const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand(
requestHeaders,
requestCookies,
requestBody,
requestUrl,
protocol,
methodType,
);

expect(curlCommand).toEqual('curl command is not supported');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Dictionary } from '@hypertrace/common';

export abstract class CurlCommandGeneratorUtil {
private static readonly CURL_COMMAND_NAME: string = 'curl';
private static readonly HEADER_OPTION: string = '-H';
private static readonly REQUEST_OPTION: string = '-X';
private static readonly BODY_OPTION: string = '-d';
private static readonly COOKIES_OPTION: string = '-b';
private static readonly SINGLE_SPACE: string = ' ';
private static readonly SINGLE_QUOTES_DELIMITER_CHAR: string = "\\'";
private static readonly SINGLE_QUOTE_CHAR: string = "'";
private static readonly SEMI_COLON_CHAR: string = ';';
private static readonly COLON_CHAR: string = ':';
private static readonly EQUALS_CHAR: string = '=';
private static readonly GET_METHOD: string = 'GET';
private static readonly DELETE_METHOD: string = 'DELETE';
private static readonly NOT_SUPPORTED_MESSAGE: string = 'curl command is not supported';

public static generateCurlCommand(
requestHeaders: Dictionary<unknown>,
requestCookies: Dictionary<unknown>,
requestBody: string,
requestUrl: string,
protocol: string,
methodType: string,
): string {
let curlCommand: string = '';

if (protocol === Protocol.PROTOCOL_HTTP || protocol === Protocol.PROTOCOL_HTTPS) {
curlCommand += `${this.CURL_COMMAND_NAME}${this.SINGLE_SPACE}${this.REQUEST_OPTION}${this.SINGLE_SPACE}${methodType}${this.SINGLE_SPACE}`;

if (Object.entries(requestHeaders).length > 0) {
curlCommand += `${this.getHeadersAsString(requestHeaders)}`;
}

if (Object.entries(requestCookies).length > 0) {
curlCommand += `${this.getCookiesAsString(requestCookies)}`;
}

// { POST, PUT, PATCH } methodType will have a body, and { GET, DELETE } will not.
if (!(methodType.includes(this.GET_METHOD) || methodType.includes(this.DELETE_METHOD))) {
curlCommand += `${this.BODY_OPTION}${this.SINGLE_SPACE}${this.SINGLE_QUOTE_CHAR}${this.getEnrichedBody(
requestBody,
)}${this.SINGLE_QUOTE_CHAR}${this.SINGLE_SPACE}`;
}

curlCommand += requestUrl;
} else {
curlCommand += this.NOT_SUPPORTED_MESSAGE;
}

return curlCommand;
}

private static getHeadersAsString(requestHeaders: Dictionary<unknown>): string {
return Object.entries(requestHeaders)
.map(
([key, value]) =>
`${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}`,
)
.join('');
}

private static getCookiesAsString(requestCookies: Dictionary<unknown>): string {
let cookiesString: string = '';

cookiesString += `${this.COOKIES_OPTION}${this.SINGLE_SPACE}${this.SINGLE_QUOTE_CHAR}`;

Object.entries(requestCookies).forEach(([key, value]) => {
cookiesString += `${key}${this.EQUALS_CHAR}${value}${this.SEMI_COLON_CHAR}`;
});

if (cookiesString[cookiesString.length - 1] === this.SEMI_COLON_CHAR) {
cookiesString = cookiesString.substr(0, cookiesString.length - 1);
}

cookiesString += `${this.SINGLE_QUOTE_CHAR}${this.SINGLE_SPACE}`;

return cookiesString;
}

private static getEnrichedBody(body: string): string {
return body.replaceAll(this.SINGLE_QUOTE_CHAR, this.SINGLE_QUOTES_DELIMITER_CHAR);
}
}

const enum Protocol {
PROTOCOL_HTTP = 'HTTP',
PROTOCOL_HTTPS = 'HTTPS',
}