Skip to content

Commit 468bc1b

Browse files
HazATkamilogorek
authored andcommitted
feat: Add API class to help with endpoints (#1459)
* feat: Add API class to help with endpoints * ref: API cleanup * fix: Tests + Lint * fix: Remove overwrite composeurl
1 parent 0c12c1b commit 468bc1b

File tree

8 files changed

+172
-102
lines changed

8 files changed

+172
-102
lines changed

packages/browser/src/backend.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Backend, DSN, Options, SentryError } from '@sentry/core';
2-
import { SentryEvent, SentryResponse } from '@sentry/types';
1+
import { Backend, logger, Options, SentryError } from '@sentry/core';
2+
import { SentryEvent, SentryResponse, Status } from '@sentry/types';
33
import { isDOMError, isDOMException, isError, isErrorEvent, isPlainObject } from '@sentry/utils/is';
44
import { supportsFetch } from '@sentry/utils/supports';
55
import { eventFromStacktrace, getEventOptionsFromPlainObject, prepareFramesForEvent } from './parsers';
@@ -132,18 +132,16 @@ export class BrowserBackend implements Backend {
132132
* @inheritDoc
133133
*/
134134
public async sendEvent(event: SentryEvent): Promise<SentryResponse> {
135-
let dsn: DSN;
136-
137135
if (!this.options.dsn) {
138-
throw new SentryError('Cannot sendEvent without a valid DSN');
139-
} else {
140-
dsn = new DSN(this.options.dsn);
136+
logger.warn(`Event has been skipped because no DSN is configured.`);
137+
// We do nothing in case there is no DSN
138+
return { status: Status.Skipped };
141139
}
142140

143-
const transportOptions = this.options.transportOptions ? this.options.transportOptions : { dsn };
141+
const transportOptions = this.options.transportOptions ? this.options.transportOptions : { dsn: this.options.dsn };
144142

145143
const transport = this.options.transport
146-
? new this.options.transport({ dsn })
144+
? new this.options.transport({ dsn: this.options.dsn })
147145
: supportsFetch()
148146
? new FetchTransport(transportOptions)
149147
: new XHRTransport(transportOptions);

packages/browser/src/client.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BaseClient, DSN, SentryError } from '@sentry/core';
1+
import { API, BaseClient, SentryError } from '@sentry/core';
22
import { DSNLike } from '@sentry/types';
33
import { getGlobalObject } from '@sentry/utils/misc';
44
import { BrowserBackend, BrowserOptions } from './backend';
@@ -57,34 +57,9 @@ export class BrowserClient extends BaseClient<BrowserBackend, BrowserOptions> {
5757
throw new SentryError('Missing `DSN` option in showReportDialog call');
5858
}
5959

60-
const encodedOptions = [];
61-
for (const key in options) {
62-
if (key === 'user') {
63-
const user = options.user;
64-
if (!user) {
65-
continue;
66-
}
67-
68-
if (user.name) {
69-
encodedOptions.push(`name=${encodeURIComponent(user.name)}`);
70-
}
71-
if (user.email) {
72-
encodedOptions.push(`email=${encodeURIComponent(user.email)}`);
73-
}
74-
} else {
75-
encodedOptions.push(`${encodeURIComponent(key)}=${encodeURIComponent(options[key] as string)}`);
76-
}
77-
}
78-
79-
const parsedDSN = new DSN(dsn);
80-
const protocol = parsedDSN.protocol ? `${parsedDSN.protocol}:` : '';
81-
const port = parsedDSN.port ? `:${parsedDSN.port}` : '';
82-
const path = parsedDSN.path ? `/${parsedDSN.path}` : '';
83-
const src = `${protocol}//${parsedDSN.host}${port}${path}/api/embed/error-page/?${encodedOptions.join('&')}`;
84-
8560
const script = document.createElement('script');
8661
script.async = true;
87-
script.src = src;
62+
script.src = new API(dsn).getReportDialogEndpoint(options);
8863
(document.head || document.body).appendChild(script);
8964
}
9065
}

packages/browser/src/transports/base.ts

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { DSN, SentryError } from '@sentry/core';
2-
import { DSNComponents, SentryEvent, SentryResponse, Transport, TransportOptions } from '@sentry/types';
3-
import { urlEncode } from '@sentry/utils/object';
1+
import { API, SentryError } from '@sentry/core';
2+
import { SentryEvent, SentryResponse, Transport, TransportOptions } from '@sentry/types';
43

54
/** Base Transport class implementation */
65
export abstract class BaseTransport implements Transport {
@@ -10,33 +9,7 @@ export abstract class BaseTransport implements Transport {
109
public url: string;
1110

1211
public constructor(public options: TransportOptions) {
13-
this.url = this.composeUrl(new DSN(options.dsn));
14-
}
15-
16-
/**
17-
* @inheritDoc
18-
*/
19-
public composeUrl(dsn: DSNComponents): string {
20-
const auth = {
21-
sentry_key: dsn.user,
22-
sentry_secret: '',
23-
sentry_version: '7',
24-
};
25-
26-
if (dsn.pass) {
27-
auth.sentry_secret = dsn.pass;
28-
} else {
29-
delete auth.sentry_secret;
30-
}
31-
32-
const protocol = dsn.protocol ? `${dsn.protocol}:` : '';
33-
const port = dsn.port ? `:${dsn.port}` : '';
34-
const path = dsn.path ? `/${dsn.path}` : '';
35-
const endpoint = `${protocol}//${dsn.host}${port}${path}/api/${dsn.projectId}/store/`;
36-
37-
// Auth is intentionally sent as part of query string (NOT as custom HTTP header)
38-
// to avoid preflight CORS requests
39-
return `${endpoint}?${urlEncode(auth)}`;
12+
this.url = new API(this.options.dsn).getStoreEndpointWithUrlEncodedAuth();
4013
}
4114

4215
/**
Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
import { DSNComponents } from '@sentry/types';
21
import { expect } from 'chai';
32
import { BaseTransport } from '../../src/transports/base';
43

54
const testDSN = 'https://123@sentry.io/42';
65

76
class SimpleTransport extends BaseTransport {}
8-
// tslint:disable-next-line:max-classes-per-file
9-
class ComplexTransport extends BaseTransport {
10-
public composeUrl(dsn: DSNComponents): string {
11-
return `https://${dsn.host}/${dsn.user}`;
12-
}
13-
}
147

158
describe('BaseTransport', () => {
169
it('doesnt provide send() implementation', async () => {
@@ -23,13 +16,8 @@ describe('BaseTransport', () => {
2316
}
2417
});
2518

26-
it('provides composeEndpointUrl() implementation', () => {
19+
it('has correct endpoint url', () => {
2720
const transport = new SimpleTransport({ dsn: testDSN });
2821
expect(transport.url).equal('https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7');
2922
});
30-
31-
it('allows overriding composeEndpointUrl() implementation', () => {
32-
const transport = new ComplexTransport({ dsn: testDSN });
33-
expect(transport.url).equal('https://sentry.io/123');
34-
});
3523
});

packages/core/src/api.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { DSNLike } from '@sentry/types';
2+
import { urlEncode } from '@sentry/utils/object';
3+
import { DSN } from './dsn';
4+
5+
const SENTRY_API_VERSION = '7';
6+
7+
/** Helper class to provide urls to different Sentry endpoints. */
8+
export class API {
9+
/** The internally used DSN object. */
10+
private readonly dsnObject: DSN;
11+
/** Create a new instance of API */
12+
public constructor(public dsn: DSNLike) {
13+
this.dsnObject = new DSN(dsn);
14+
}
15+
16+
/** Returns the DSN object. */
17+
public getDSN(): DSN {
18+
return this.dsnObject;
19+
}
20+
21+
/** Returns a string with auth headers in the url to the store endpoint. */
22+
public getStoreEndpoint(): string {
23+
return `${this.getBaseUrl()}${this.getStoreEndpointPath()}`;
24+
}
25+
26+
/** Returns the store endpoint with auth added in url encoded. */
27+
public getStoreEndpointWithUrlEncodedAuth(): string {
28+
const dsn = this.dsnObject;
29+
const auth = {
30+
sentry_key: dsn.user,
31+
sentry_version: SENTRY_API_VERSION,
32+
};
33+
// Auth is intentionally sent as part of query string (NOT as custom HTTP header)
34+
// to avoid preflight CORS requests
35+
return `${this.getStoreEndpoint()}?${urlEncode(auth)}`;
36+
}
37+
38+
/** Returns the base path of the url including the port. */
39+
private getBaseUrl(): string {
40+
const dsn = this.dsnObject;
41+
const protocol = dsn.protocol ? `${dsn.protocol}:` : '';
42+
const port = dsn.port ? `:${dsn.port}` : '';
43+
return `${protocol}//${dsn.host}${port}`;
44+
}
45+
46+
/** Returns only the path component for the store endpoint. */
47+
public getStoreEndpointPath(): string {
48+
const dsn = this.dsnObject;
49+
return `${dsn.path ? `/${dsn.path}` : ''}/api/${dsn.projectId}/store/`;
50+
}
51+
52+
/** Returns an object that can be used in request headers. */
53+
public getRequestHeaders(clientName: string, clientVersion: string): { [key: string]: string } {
54+
const dsn = this.dsnObject;
55+
const header = [`Sentry sentry_version=${SENTRY_API_VERSION}`];
56+
header.push(`sentry_timestamp=${new Date().getTime()}`);
57+
header.push(`sentry_client=${clientName}/${clientVersion}`);
58+
header.push(`sentry_key=${dsn.user}`);
59+
return {
60+
'Content-Type': 'application/json',
61+
'X-Sentry-Auth': header.join(', '),
62+
};
63+
}
64+
65+
/** Returns the url to the report dialog endpoint. */
66+
public getReportDialogEndpoint(
67+
dialogOptions: {
68+
[key: string]: any;
69+
user?: { name?: string; email?: string };
70+
} = {},
71+
): string {
72+
const dsn = this.dsnObject;
73+
const endpoint = `${this.getBaseUrl()}${dsn.path ? `/${dsn.path}` : ''}/api/embed/error-page/`;
74+
75+
const encodedOptions = [];
76+
for (const key in dialogOptions) {
77+
if (key === 'user') {
78+
if (!dialogOptions.user) {
79+
continue;
80+
}
81+
if (dialogOptions.user.name) {
82+
encodedOptions.push(`name=${encodeURIComponent(dialogOptions.user.name)}`);
83+
}
84+
if (dialogOptions.user.email) {
85+
encodedOptions.push(`email=${encodeURIComponent(dialogOptions.user.email)}`);
86+
}
87+
} else {
88+
encodedOptions.push(`${encodeURIComponent(key)}=${encodeURIComponent(dialogOptions[key] as string)}`);
89+
}
90+
}
91+
if (encodedOptions.length) {
92+
return `${endpoint}?${encodedOptions.join('&')}`;
93+
}
94+
95+
return endpoint;
96+
}
97+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { logger } from './logger';
22
export { captureException, captureMessage, configureScope } from '@sentry/minimal';
33
export { Hub, Scope } from '@sentry/hub';
4+
export { API } from './api';
45
export { BackendClass, BaseClient } from './base';
56
export { DSN } from './dsn';
67
export { SentryError } from './error';

packages/core/test/lib/api.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { API } from '../../src/api';
2+
import { DSN } from '../../src/dsn';
3+
4+
const dsnPublic = 'https://abc@sentry.io:1234/subpath/123';
5+
6+
describe('API', () => {
7+
test('getStoreEndpoint', () => {
8+
expect(new API(dsnPublic).getStoreEndpointWithUrlEncodedAuth()).toEqual(
9+
'https://sentry.io:1234/subpath/api/123/store/?sentry_key=abc&sentry_version=7',
10+
);
11+
expect(new API(dsnPublic).getStoreEndpoint()).toEqual('https://sentry.io:1234/subpath/api/123/store/');
12+
});
13+
14+
test('getRequestHeaders', () => {
15+
expect(new API(dsnPublic).getRequestHeaders('a', '1.0')).toMatchObject({
16+
'Content-Type': 'application/json',
17+
'X-Sentry-Auth': expect.stringMatching(
18+
/^Sentry sentry_version=\d, sentry_timestamp=\d+, sentry_client=a\/1\.0, sentry_key=abc$/,
19+
),
20+
});
21+
});
22+
test('getReportDialogEndpoint', () => {
23+
expect(new API(dsnPublic).getReportDialogEndpoint({})).toEqual(
24+
'https://sentry.io:1234/subpath/api/embed/error-page/',
25+
);
26+
expect(
27+
new API(dsnPublic).getReportDialogEndpoint({
28+
eventId: 'abc',
29+
testy: '2',
30+
}),
31+
).toEqual('https://sentry.io:1234/subpath/api/embed/error-page/?eventId=abc&testy=2');
32+
33+
expect(
34+
new API(dsnPublic).getReportDialogEndpoint({
35+
eventId: 'abc',
36+
user: {
37+
email: 'email',
38+
name: 'yo',
39+
},
40+
}),
41+
).toEqual('https://sentry.io:1234/subpath/api/embed/error-page/?eventId=abc&name=yo&email=email');
42+
43+
expect(
44+
new API(dsnPublic).getReportDialogEndpoint({
45+
eventId: 'abc',
46+
user: undefined,
47+
}),
48+
).toEqual('https://sentry.io:1234/subpath/api/embed/error-page/?eventId=abc');
49+
});
50+
test('getDSN', () => {
51+
expect(new API(dsnPublic).getDSN()).toEqual(new DSN(dsnPublic));
52+
});
53+
});

packages/node/src/transports/base.ts

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DSN, SentryError } from '@sentry/core';
1+
import { API, SentryError } from '@sentry/core';
22
import { SentryEvent, SentryResponse, Status, Transport, TransportOptions } from '@sentry/types';
33
import { serialize } from '@sentry/utils/object';
44
import * as http from 'http';
@@ -15,46 +15,31 @@ export interface HTTPRequest {
1515

1616
/** Base Transport class implementation */
1717
export abstract class BaseTransport implements Transport {
18-
/** DSN object */
19-
protected dsn: DSN;
18+
/** API object */
19+
protected api: API;
2020

2121
/** The Agent used for corresponding transport */
2222
protected client: http.Agent | https.Agent | undefined;
2323

2424
/** Create instance and set this.dsn */
2525
public constructor(public options: TransportOptions) {
26-
this.dsn = new DSN(options.dsn);
27-
}
28-
29-
/** Returns a Sentry auth header string */
30-
private getAuthHeader(): string {
31-
const header = ['Sentry sentry_version=7'];
32-
header.push(`sentry_timestamp=${new Date().getTime()}`);
33-
34-
header.push(`sentry_client=${SDK_NAME}/${SDK_VERSION}`);
35-
36-
header.push(`sentry_key=${this.dsn.user}`);
37-
if (this.dsn.pass) {
38-
header.push(`sentry_secret=${this.dsn.pass}`);
39-
}
40-
return header.join(', ');
26+
this.api = new API(options.dsn);
4127
}
4228

4329
/** Returns a build request option object used by request */
4430
protected getRequestOptions(): http.RequestOptions {
4531
const headers = {
46-
'Content-Type': 'application/json',
47-
'X-Sentry-Auth': this.getAuthHeader(),
32+
...this.api.getRequestHeaders(SDK_NAME, SDK_VERSION),
4833
...this.options.headers,
4934
};
5035

5136
return {
5237
agent: this.client,
5338
headers,
54-
hostname: this.dsn.host,
39+
hostname: this.api.getDSN().host,
5540
method: 'POST',
56-
path: `${this.dsn.path ? `/${this.dsn.path}` : ''}/api/${this.dsn.projectId}/store/`,
57-
port: this.dsn.port,
41+
path: this.api.getStoreEndpointPath(),
42+
port: this.api.getDSN().port,
5843
};
5944
}
6045

0 commit comments

Comments
 (0)