@stainlessdev/docs-xray 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +60 -0
- package/package.json +32 -0
- package/public/xray-badge.js +179 -0
- package/src/astro/XRayPageSetup.astro +57 -0
- package/src/astro/index.ts +1 -0
- package/src/components/XRayPage.tsx +1243 -0
- package/src/components/XRayTabBadge.tsx +219 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +49 -0
- package/src/integration.ts +53 -0
- package/src/styles/xray.css +1032 -0
- package/src/types.ts +56 -0
- package/src/utils/index.ts +218 -0
- package/src/utils/spec.ts +120 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type XrayRequestLog = {
|
|
2
|
+
request_id: string;
|
|
3
|
+
request_method: string;
|
|
4
|
+
request_path: string;
|
|
5
|
+
request_route: string;
|
|
6
|
+
response_status_code: number;
|
|
7
|
+
timestamp: string;
|
|
8
|
+
service: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type XrayRequestLogDetail = XrayRequestLog & {
|
|
12
|
+
duration_us: number;
|
|
13
|
+
ip: string | null;
|
|
14
|
+
request_body: string;
|
|
15
|
+
request_body_encoding: string;
|
|
16
|
+
request_body_truncated: boolean | null;
|
|
17
|
+
request_headers: Record<string, string[]> | null;
|
|
18
|
+
response_body: string;
|
|
19
|
+
response_body_encoding: string;
|
|
20
|
+
response_body_truncated: boolean | null;
|
|
21
|
+
response_headers: Record<string, string[]> | null;
|
|
22
|
+
user_id: string | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SessionUser = {
|
|
26
|
+
user: {
|
|
27
|
+
name: string;
|
|
28
|
+
ip: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type FetchResult<T> = {
|
|
33
|
+
data: T;
|
|
34
|
+
status: 'ok' | 'unauthorized' | 'error';
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type EndpointInfo = { title: string; href: string };
|
|
38
|
+
export type EndpointMap = Record<string, EndpointInfo>;
|
|
39
|
+
|
|
40
|
+
export type ParsedUserAgent = {
|
|
41
|
+
type: 'curl' | 'sdk' | 'browser' | 'unknown';
|
|
42
|
+
name: string;
|
|
43
|
+
version?: string;
|
|
44
|
+
language?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type DetailTab = 'response' | 'request';
|
|
48
|
+
|
|
49
|
+
declare global {
|
|
50
|
+
interface Window {
|
|
51
|
+
__XRAY_API__?: string;
|
|
52
|
+
__XRAY_TAB_LINK__?: string;
|
|
53
|
+
__XRAY_ENDPOINT_MAP__?: EndpointMap;
|
|
54
|
+
__XRAY_SERVER_BASE_PATHS__?: string[];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { ParsedUserAgent, EndpointInfo, EndpointMap } from '../types';
|
|
2
|
+
|
|
3
|
+
export function parseUserAgent(ua: string | undefined | null): ParsedUserAgent {
|
|
4
|
+
if (!ua || typeof ua !== 'string') return { type: 'unknown', name: 'Unknown' };
|
|
5
|
+
|
|
6
|
+
if (ua.toLowerCase().includes('curl')) {
|
|
7
|
+
const match = ua.match(/curl\/([\d.]+)/i);
|
|
8
|
+
return { type: 'curl', name: 'curl', version: match?.[1] };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const sdkPatterns: { pattern: RegExp; language: string }[] = [
|
|
12
|
+
{ pattern: /(\w+)\/JS\s+([\d.]+)/i, language: 'TypeScript' },
|
|
13
|
+
{ pattern: /(\w+)\/Python\s+([\d.]+)/i, language: 'Python' },
|
|
14
|
+
{ pattern: /(\w+)\/Go\s+([\d.]+)/i, language: 'Go' },
|
|
15
|
+
{ pattern: /(\w+)\/Java\s+([\d.]+)/i, language: 'Java' },
|
|
16
|
+
{ pattern: /(\w+)\/Kotlin\s+([\d.]+)/i, language: 'Kotlin' },
|
|
17
|
+
{ pattern: /(\w+)\/Ruby\s+([\d.]+)/i, language: 'Ruby' },
|
|
18
|
+
{ pattern: /(\w+)\/CSharp\s+([\d.]+)/i, language: 'C#' },
|
|
19
|
+
{ pattern: /(\w+)\/PHP\s+([\d.]+)/i, language: 'PHP' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
for (const { pattern, language } of sdkPatterns) {
|
|
23
|
+
const match = ua.match(pattern);
|
|
24
|
+
if (match) {
|
|
25
|
+
return { type: 'sdk', name: match[1], version: match[2], language };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (ua.includes('Chrome') && !ua.includes('Edg')) {
|
|
30
|
+
const match = ua.match(/Chrome\/([\d.]+)/);
|
|
31
|
+
return { type: 'browser', name: 'Chrome', version: match?.[1] };
|
|
32
|
+
}
|
|
33
|
+
if (ua.includes('Firefox')) {
|
|
34
|
+
const match = ua.match(/Firefox\/([\d.]+)/);
|
|
35
|
+
return { type: 'browser', name: 'Firefox', version: match?.[1] };
|
|
36
|
+
}
|
|
37
|
+
if (ua.includes('Safari') && !ua.includes('Chrome')) {
|
|
38
|
+
const match = ua.match(/Version\/([\d.]+)/);
|
|
39
|
+
return { type: 'browser', name: 'Safari', version: match?.[1] };
|
|
40
|
+
}
|
|
41
|
+
if (ua.includes('Edg')) {
|
|
42
|
+
const match = ua.match(/Edg\/([\d.]+)/);
|
|
43
|
+
return { type: 'browser', name: 'Edge', version: match?.[1] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { type: 'unknown', name: 'Unknown' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatUserAgentSummary(parsed: ParsedUserAgent): string {
|
|
50
|
+
if (parsed.type === 'sdk' && parsed.language) {
|
|
51
|
+
return `${parsed.language} SDK${parsed.version ? ` v${parsed.version}` : ''}`;
|
|
52
|
+
}
|
|
53
|
+
if (parsed.version) {
|
|
54
|
+
return `${parsed.name} ${parsed.version}`;
|
|
55
|
+
}
|
|
56
|
+
return parsed.name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeRouteParams(route: string): string {
|
|
60
|
+
return route.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getEndpointInfo(
|
|
64
|
+
method: string,
|
|
65
|
+
route: string | null,
|
|
66
|
+
endpointMap: EndpointMap | undefined,
|
|
67
|
+
serverBasePaths: string[] | undefined,
|
|
68
|
+
): EndpointInfo | null {
|
|
69
|
+
if (!endpointMap || !route) return null;
|
|
70
|
+
|
|
71
|
+
const normalizedRoute = normalizeRouteParams(route);
|
|
72
|
+
const lowerMethod = method.toLowerCase();
|
|
73
|
+
|
|
74
|
+
const exactKey = `${lowerMethod} ${normalizedRoute}`;
|
|
75
|
+
if (endpointMap[exactKey]) {
|
|
76
|
+
return endpointMap[exactKey];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (serverBasePaths) {
|
|
80
|
+
for (const basePath of serverBasePaths) {
|
|
81
|
+
if (normalizedRoute.startsWith(basePath)) {
|
|
82
|
+
const withoutPrefix = normalizedRoute.slice(basePath.length) || '/';
|
|
83
|
+
const withoutPrefixKey = `${lowerMethod} ${withoutPrefix}`;
|
|
84
|
+
if (endpointMap[withoutPrefixKey]) {
|
|
85
|
+
return endpointMap[withoutPrefixKey];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getStatusText(code: number): string {
|
|
95
|
+
const statusTexts: Record<number, string> = {
|
|
96
|
+
200: 'OK',
|
|
97
|
+
201: 'Created',
|
|
98
|
+
202: 'Accepted',
|
|
99
|
+
204: 'No Content',
|
|
100
|
+
206: 'Partial Content',
|
|
101
|
+
301: 'Moved Permanently',
|
|
102
|
+
302: 'Found',
|
|
103
|
+
303: 'See Other',
|
|
104
|
+
304: 'Not Modified',
|
|
105
|
+
307: 'Temporary Redirect',
|
|
106
|
+
308: 'Permanent Redirect',
|
|
107
|
+
400: 'Bad Request',
|
|
108
|
+
401: 'Unauthorized',
|
|
109
|
+
403: 'Forbidden',
|
|
110
|
+
404: 'Not Found',
|
|
111
|
+
405: 'Method Not Allowed',
|
|
112
|
+
408: 'Request Timeout',
|
|
113
|
+
409: 'Conflict',
|
|
114
|
+
410: 'Gone',
|
|
115
|
+
413: 'Payload Too Large',
|
|
116
|
+
415: 'Unsupported Media Type',
|
|
117
|
+
422: 'Unprocessable Entity',
|
|
118
|
+
429: 'Too Many Requests',
|
|
119
|
+
500: 'Internal Server Error',
|
|
120
|
+
501: 'Not Implemented',
|
|
121
|
+
502: 'Bad Gateway',
|
|
122
|
+
503: 'Service Unavailable',
|
|
123
|
+
504: 'Gateway Timeout',
|
|
124
|
+
};
|
|
125
|
+
return statusTexts[code] || '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function formatRelativeTime(timestamp: string): string {
|
|
129
|
+
const date = new Date(timestamp);
|
|
130
|
+
const now = new Date();
|
|
131
|
+
const diffMs = now.getTime() - date.getTime();
|
|
132
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
133
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
134
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
135
|
+
|
|
136
|
+
if (diffSec < 10) return 'just now';
|
|
137
|
+
if (diffSec < 60) return `${diffSec}s ago`;
|
|
138
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
139
|
+
if (diffHour < 24) return `${diffHour}h ago`;
|
|
140
|
+
|
|
141
|
+
return date.toLocaleDateString('en-US', {
|
|
142
|
+
month: 'short',
|
|
143
|
+
day: 'numeric',
|
|
144
|
+
hour: '2-digit',
|
|
145
|
+
minute: '2-digit',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatTime(timestamp: string): string {
|
|
150
|
+
const date = new Date(timestamp);
|
|
151
|
+
return date.toLocaleTimeString('en-US', {
|
|
152
|
+
hour: '2-digit',
|
|
153
|
+
minute: '2-digit',
|
|
154
|
+
second: '2-digit',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function formatDate(timestamp: string): string {
|
|
159
|
+
const date = new Date(timestamp);
|
|
160
|
+
return date.toLocaleDateString('en-US', {
|
|
161
|
+
month: 'short',
|
|
162
|
+
day: 'numeric',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getStatusClass(status: number): string {
|
|
167
|
+
if (status >= 500) return 'xray-page__status--error';
|
|
168
|
+
if (status >= 400) return 'xray-page__status--warning';
|
|
169
|
+
return 'xray-page__status--success';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Storage utilities
|
|
173
|
+
const STORAGE_KEY = 'xray-badge-data';
|
|
174
|
+
const PENDING_COUNT_KEY = 'xray-pending-count';
|
|
175
|
+
|
|
176
|
+
export function loadSeenIdsFromStorage(): Set<string> {
|
|
177
|
+
try {
|
|
178
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
179
|
+
if (stored) {
|
|
180
|
+
const data = JSON.parse(stored);
|
|
181
|
+
if (Array.isArray(data.seenIds)) {
|
|
182
|
+
return new Set(data.seenIds);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Ignore parse errors
|
|
187
|
+
}
|
|
188
|
+
return new Set();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function saveSeenIdsToStorage(seenIds: Set<string>): void {
|
|
192
|
+
try {
|
|
193
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ seenIds: Array.from(seenIds) }));
|
|
194
|
+
} catch {
|
|
195
|
+
// Ignore storage errors
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function savePendingCountToStorage(count: number): void {
|
|
200
|
+
try {
|
|
201
|
+
localStorage.setItem(PENDING_COUNT_KEY, JSON.stringify({ count }));
|
|
202
|
+
} catch {
|
|
203
|
+
// Ignore storage errors
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function loadPendingCountFromStorage(): number {
|
|
208
|
+
try {
|
|
209
|
+
const stored = localStorage.getItem(PENDING_COUNT_KEY);
|
|
210
|
+
if (stored) {
|
|
211
|
+
const data = JSON.parse(stored);
|
|
212
|
+
return data.count ?? 0;
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// Ignore parse errors
|
|
216
|
+
}
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { EndpointMap } from '../types';
|
|
2
|
+
|
|
3
|
+
export type SpecMethod = {
|
|
4
|
+
endpoint: string;
|
|
5
|
+
title: string;
|
|
6
|
+
stainlessPath: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Walk a Stainless spec tree and extract all HTTP methods.
|
|
11
|
+
*/
|
|
12
|
+
export function* walkSpec(spec: any): Generator<SpecMethod> {
|
|
13
|
+
function* walkResource(resource: any, parentPath: string): Generator<SpecMethod> {
|
|
14
|
+
const resourcePath = parentPath ? `${parentPath}.${resource.name}` : resource.name;
|
|
15
|
+
const stainlessResourcePath = `(resource) ${resourcePath}`;
|
|
16
|
+
|
|
17
|
+
// Yield methods
|
|
18
|
+
if (resource.methods) {
|
|
19
|
+
for (const method of Object.values(resource.methods) as any[]) {
|
|
20
|
+
yield {
|
|
21
|
+
endpoint: method.endpoint,
|
|
22
|
+
title: method.summary || method.title || method.name,
|
|
23
|
+
stainlessPath: `${stainlessResourcePath} > (method) ${method.name}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Recurse into subresources
|
|
29
|
+
if (resource.subresources) {
|
|
30
|
+
for (const subresource of Object.values(resource.subresources) as any[]) {
|
|
31
|
+
yield* walkResource(subresource, resourcePath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (spec.resources) {
|
|
37
|
+
for (const resource of Object.values(spec.resources) as any[]) {
|
|
38
|
+
yield* walkResource(resource, '');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate a documentation route from a Stainless path.
|
|
45
|
+
*
|
|
46
|
+
* @param basePath - The base path for the docs (e.g., '/')
|
|
47
|
+
* @param stainlessPath - A path like "(resource) pets > (method) list"
|
|
48
|
+
* @returns The documentation href or null if the path couldn't be parsed
|
|
49
|
+
*/
|
|
50
|
+
export function generateRoute(basePath: string, stainlessPath: string): string | null {
|
|
51
|
+
// Parse stainless path like "(resource) pets > (method) list"
|
|
52
|
+
const match = stainlessPath.match(/\(resource\) ([^\s>]+)( > \(method\) ([^\s]+))?/);
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
|
|
55
|
+
const resourceParts = match[1].split('.');
|
|
56
|
+
const method = match[3];
|
|
57
|
+
|
|
58
|
+
const path = [basePath.endsWith('/') ? basePath.slice(0, -1) : basePath];
|
|
59
|
+
path.push('resources', ...resourceParts.flatMap((part, i) => i > 0 ? ['subresources', part] : [part]));
|
|
60
|
+
if (method) path.push('methods', method);
|
|
61
|
+
|
|
62
|
+
return path.join('/');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type BuildEndpointMapOptions = {
|
|
66
|
+
/** The CMS port to fetch the spec from */
|
|
67
|
+
cmsPort: number | string;
|
|
68
|
+
/** The base path for documentation links */
|
|
69
|
+
basePath: string;
|
|
70
|
+
/** Default server base paths if not returned by the CMS */
|
|
71
|
+
defaultServerBasePaths?: string[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type BuildEndpointMapResult = {
|
|
75
|
+
endpointMap: EndpointMap;
|
|
76
|
+
serverBasePaths: string[];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fetch the spec from the CMS and build an endpoint map for matching
|
|
81
|
+
* X-ray requests to documentation links.
|
|
82
|
+
*/
|
|
83
|
+
export async function buildEndpointMap(
|
|
84
|
+
options: BuildEndpointMapOptions
|
|
85
|
+
): Promise<BuildEndpointMapResult> {
|
|
86
|
+
const { cmsPort, basePath, defaultServerBasePaths = ['/api/v3'] } = options;
|
|
87
|
+
|
|
88
|
+
const endpointMap: EndpointMap = {};
|
|
89
|
+
let serverBasePaths: string[] = [];
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const response = await fetch(`http://localhost:${cmsPort}/retrieve_spec`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({}),
|
|
96
|
+
});
|
|
97
|
+
const specResp = await response.json();
|
|
98
|
+
|
|
99
|
+
if (specResp.data) {
|
|
100
|
+
// Server base paths are needed to match X-ray routes (e.g., /api/v3/pet)
|
|
101
|
+
// to spec endpoints (e.g., /pet). The CMS should return these from the
|
|
102
|
+
// OpenAPI spec's servers field, but currently doesn't pass them through.
|
|
103
|
+
serverBasePaths = specResp.servers ?? defaultServerBasePaths;
|
|
104
|
+
|
|
105
|
+
for (const method of walkSpec(specResp.data)) {
|
|
106
|
+
const href = generateRoute(basePath, method.stainlessPath);
|
|
107
|
+
if (href) {
|
|
108
|
+
endpointMap[method.endpoint] = {
|
|
109
|
+
title: method.title,
|
|
110
|
+
href,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Spec not available
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { endpointMap, serverBasePaths };
|
|
120
|
+
}
|