Skip to content

Commit

Permalink
feat: copy curl command support in span detail component (#2603)
Browse files Browse the repository at this point in the history
* fix: copy curl command support in span detail component

* fix: support to generate curl command
  • Loading branch information
Greedy-Geek authored Jan 4, 2024
1 parent 7080b66 commit d9db405
Show file tree
Hide file tree
Showing 7 changed files with 258 additions and 9 deletions.
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',
}

0 comments on commit d9db405

Please sign in to comment.