Skip to content
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
2 changes: 1 addition & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@
"pageTitle": "History",
"title": "You haven't executed any requests",
"description": "It's empty here. Try:",
"link": "RESTful client:",
"link": "RESTful client",
"analytics": "Analytics",
"duration": "Request duration: ",
"status": "Status code: ",
Expand Down
2 changes: 1 addition & 1 deletion messages/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@
"pageTitle": "История",
"title": "Вы еще не выполнили ни одного запроса",
"description": "Здесь пусто. Попробуйте:",
"link": "REST клиент:",
"link": "REST клиент",
"analytics": "Аналитика",
"duration": "Длительность запроса: ",
"status": "Код статуса: ",
Expand Down
8 changes: 5 additions & 3 deletions src/app/[locale]/(dashboard)/history/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { HistoryDynamic } from '@/features/history/components/history';
import { mockHistoryData } from '@/testing/mocks/history';
import { fetchHistory } from '@/utils/supabase/fetch-history';

export default function HistoryPage() {
return <HistoryDynamic historyData={mockHistoryData} />;
export default async function HistoryPage() {
const data = await fetchHistory();

return <HistoryDynamic historyData={data} />;
}
20 changes: 17 additions & 3 deletions src/features/history/components/history-item.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { cn } from '@heroui/react';
import { StatusCodes } from 'http-status-codes';
import { useLocale, useTranslations } from 'next-intl';
import { parse } from 'valibot';

import type { HistoryData } from '@/features/history/types/history-data';

import { ChevronIcon } from '@/components/icons/chevron';
import { ANALYTIC_KEYS } from '@/features/history/constants/analitic-keys';
import { HeadersSchema } from '@/features/rest-client/schemas/proxy-schema';
import { Link } from '@/i18n/navigation';
import { formateTimestamp } from '@/utils/format-timestamp';
import { generateRouteUrl } from '@/utils/route-generator';

type AnalyticKeys = (typeof ANALYTIC_KEYS)[number];
Expand All @@ -15,17 +18,28 @@ type HistoryItemProps = {
method: string;
url: string;
body?: string;
headers: Record<string, string>;
headers: string;
analytics: Partial<{ [K in AnalyticKeys]?: HistoryData[K] }>;
};

export function HistoryItem({ method, url, body, headers, analytics }: HistoryItemProps) {
const t = useTranslations('History');
const locale = useLocale();
const routeUrl = generateRouteUrl(method, url, locale, body, headers);

let parsedHeaders: Record<string, string> = {};

try {
parsedHeaders = parse(HeadersSchema, JSON.parse(headers));
} catch {
parsedHeaders = {};
}

const routeUrl = generateRouteUrl(method, url, '', body, parsedHeaders);
const isSuccess =
analytics.status && analytics.status >= StatusCodes.OK && analytics.status < StatusCodes.MULTIPLE_CHOICES;

const formattedTimestamp = analytics.timestamp && formateTimestamp(locale, analytics.timestamp);

return (
<li className="mb-3">
<Link
Expand All @@ -46,7 +60,7 @@ export function HistoryItem({ method, url, body, headers, analytics }: HistoryIt
{ANALYTIC_KEYS.map((key) => (
<p key={key} data-testid={`analytic-${key}`}>
<strong>{t(key)}</strong>
{analytics[key] ?? '-'}
{key === 'timestamp' ? formattedTimestamp : (analytics[key] ?? '-')}
</p>
))}
</div>
Expand Down
7 changes: 6 additions & 1 deletion src/features/history/components/history.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { HistoryData } from '@/features/history/components/history';
import { ANALYTIC_KEYS } from '@/features/history/constants/analitic-keys';
import { mockHistoryData } from '@/testing/mocks/history';
import { renderWithProviders } from '@/testing/utils/render-with-providers';
import { formateTimestamp } from '@/utils/format-timestamp';

const useTranslationsMock = (key: string) => key;

Expand Down Expand Up @@ -48,9 +49,13 @@ describe('History', () => {
expect(container.getByText(data.url)).toBeInTheDocument();

ANALYTIC_KEYS.forEach((key) => {
const value = data[key] ?? '-';
let value = data[key] ?? '-';
const keyElement = within(container.getByTestId(`analytic-${key}`));

if (key === 'timestamp') {
value = formateTimestamp('en', value.toString());
}

expect(keyElement.getByText(value.toString())).toBeInTheDocument();
});
});
Expand Down
6 changes: 3 additions & 3 deletions src/features/history/components/history.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { useTranslations } from 'next-intl';
import dynamic from 'next/dynamic';

import type { HistoryData } from '@/features/history/types/history-data';
import type { HistoryData, HistoryInsertData } from '@/features/history/types/history-data';

import { Loading } from '@/components/loading/loading';
import { ROUTES } from '@/config/routes';
import { HistoryItem } from '@/features/history/components/history-item';
import { Link } from '@/i18n/navigation';

export type HistoryProps = {
historyData: HistoryData[];
historyData: HistoryInsertData[] | null;
};

export function HistoryData({ historyData }: HistoryProps) {
const t = useTranslations('History');

const renderContent = () => {
if (historyData.length === 0) {
if (!historyData || historyData.length === 0) {
return (
<div className="flex h-[50vh] w-full flex-col items-center justify-center text-center">
<h2 className="mb-2">{t('title')}</h2>
Expand Down
4 changes: 4 additions & 0 deletions src/features/history/types/history-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ export type HistoryData = {
headers: Record<string, string>;
body?: string;
};

export type HistoryInsertData = Omit<HistoryData, 'headers'> & {
headers: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { HttpRequestForm } from './http-request-form';

const mockExecuteRequest = vi.fn();

vi.mock('@supabase/ssr', () => ({
createBrowserClient: vi.fn(),
}));

describe('HttpRequestForm', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { RequestBodyEditor } from '@/features/rest-client/components/request-bod
import { renderWithProviders } from '@/testing/utils/render-with-providers';
import { renderWithUserEvent } from '@/testing/utils/render-with-user-event';

vi.mock('@supabase/ssr', () => ({
createBrowserClient: vi.fn(),
}));

describe('RequestBodyEditor', () => {
it('should render with content', () => {
renderWithProviders(<RequestBodyEditor body="test content" mode="text" />);
Expand Down
4 changes: 4 additions & 0 deletions src/features/rest-client/components/response-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const mockResponse = {
responseSize: 0,
};

vi.mock('@supabase/ssr', () => ({
createBrowserClient: vi.fn(),
}));

describe('ResponseSection', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
6 changes: 4 additions & 2 deletions src/features/rest-client/schemas/proxy-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ import { object, optional, record, string, number, picklist } from 'valibot';

import { HTTP_METHODS } from '@/features/rest-client/constants/http-request';

export const HeadersSchema = record(string(), string());

export const ProxyRequestSchema = object({
url: string(),
method: picklist(HTTP_METHODS),
headers: record(string(), string()),
headers: HeadersSchema,
body: optional(string()),
});

export const ProxyResponseSchema = object({
status: number(),
statusText: string(),
headers: record(string(), string()),
headers: HeadersSchema,
body: string(),
error: optional(string()),
});
4 changes: 1 addition & 3 deletions src/stores/auth-context/auth-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { UserData } from '@/stores/auth-context/types';

import { useRouter } from '@/i18n/navigation';
import { AuthContext } from '@/stores/auth-context/context';
import { createClient } from '@/utils/supabase/client';
import { supabase } from '@/utils/supabase/history-insert';

type AuthProviderProps = {
children: ReactNode;
Expand All @@ -19,8 +19,6 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
const [loading, setLoading] = useState(true);

useEffect(() => {
const supabase = createClient();

void supabase.auth.getSession().then(({ data: { session } }: { data: { session: Session | null } }) => {
if (session?.user.email) {
setUser({
Expand Down
2 changes: 2 additions & 0 deletions src/stores/rest-client/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ vi.mock('@/features/variables/hooks/use-replace-with-variable', () => ({
useReplaceWithVariable: () => mockReplaceVariables,
}));

vi.mock('@/utils/supabase/history-insert', () => ({ insertHistory: vi.fn(() => ({ error: null })) }));

const executeRequest = async () => {
const { executeRequest } = useRestClientStore.getState();

Expand Down
41 changes: 26 additions & 15 deletions src/stores/rest-client/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getUrlFromParams,
} from '@/features/rest-client/utils/get-parameters';
import { generateRouteUrl } from '@/utils/route-generator';
import { insertHistory } from '@/utils/supabase/history-insert';

type State = {
method: string;
Expand Down Expand Up @@ -176,30 +177,40 @@ export const useRestClientStore = create<RestClientStore>((set, get) => ({
body: bodyWithVariables,
});

if (!result.ok) {
throw new Error(`HTTP ${result.status.toString()}: ${result.statusText}`);
}

const data: unknown = await result.json();
const proxyResponse = parse(ProxyResponseSchema, data);
const duration = performance.now() - startTime;
const duration = +(performance.now() - startTime).toFixed(1);
const responseSize = new TextEncoder().encode(proxyResponse.body).length;

const dataForSave = {
status: proxyResponse.status,
headers: proxyResponse.headers,
body: proxyResponse.body,
timestamp,
duration,
requestSize,
responseSize,
};

const { error } = await insertHistory({
...dataForSave,
headers: JSON.stringify(requestHeaders),
id: crypto.randomUUID(),
error: proxyResponse.error,
method,
url,
});

if (proxyResponse.error) {
throw new Error(proxyResponse.error);
}

if (!result.ok) {
throw new Error(`HTTP ${result.status.toString()}: ${result.statusText}`);
}

set({
response: {
status: proxyResponse.status,
statusText: getReasonPhrase(proxyResponse.status),
headers: proxyResponse.headers,
body: proxyResponse.body,
timestamp,
duration,
requestSize,
responseSize,
},
response: { ...dataForSave, statusText: getReasonPhrase(proxyResponse.status), error: error?.message },
isLoading: false,
});
Comment on lines 204 to 215

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Throwing an error when result.ok is false causes the execution to jump to the catch block. The catch block then sets a generic error response, losing all the details from the actual HTTP response (like status code, headers, and body) that have already been received and parsed. This prevents the user from seeing the actual error response from the server. Non-2xx responses are valid HTTP responses and should be handled gracefully by setting them in the state, not by throwing an exception that leads to loss of information.

Suggested change
if (proxyResponse.error) {
throw new Error(proxyResponse.error);
}
if (!result.ok) {
throw new Error(`HTTP ${result.status.toString()}: ${result.statusText}`);
}
set({
response: {
status: proxyResponse.status,
statusText: getReasonPhrase(proxyResponse.status),
headers: proxyResponse.headers,
body: proxyResponse.body,
timestamp,
duration,
requestSize,
responseSize,
},
response: { ...dataForSave, statusText: getReasonPhrase(proxyResponse.status), error: error?.message },
isLoading: false,
});
if (proxyResponse.error) {
throw new Error(proxyResponse.error);
}
set({
response: { ...dataForSave, statusText: getReasonPhrase(proxyResponse.status), error: error?.message },
isLoading: false,
});


Expand Down
8 changes: 4 additions & 4 deletions src/testing/mocks/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,24 @@ export const mockHistoryData = [
id: '1',
duration: 200,
status: 500,
timestamp: 'timestampMock',
timestamp: '2025-09-21 17:59:06.064+00',
method: 'POST',
requestSize: 300,
responseSize: 300,
error:
'Invalid header value: Headers must contain only ASCII characters. Remove non-ASCII characters from header names and values.',
url: 'https://api.example.com/resource',
headers: { 'content-type': 'application/json; charset=utf-8' },
headers: `{"Accept":"*/*"}`,
},
{
id: '2',
duration: 200,
status: 200,
timestamp: 'timestampMock',
timestamp: '2025-09-21 18:09:20.336+00',
method: 'POST',
requestSize: 300,
responseSize: 300,
url: 'https://api.example.com/resource/2',
headers: { 'content-type': 'application/json; charset=utf-8' },
headers: `{"Accept":"*/*"}`,
},
];
8 changes: 8 additions & 0 deletions src/utils/format-timestamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const formateTimestamp = (locale: string, timestamp: string) =>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a typo in the function name formateTimestamp. It should be formatTimestamp. This should be corrected here and in all places where it's used for consistency and to avoid confusion.

Suggested change
export const formateTimestamp = (locale: string, timestamp: string) =>
export const formatTimestamp = (locale: string, timestamp: string) =>

new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(timestamp));
27 changes: 27 additions & 0 deletions src/utils/supabase/fetch-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { PostgrestError } from '@supabase/supabase-js';

import type { HistoryInsertData } from '@/features/history/types/history-data';

import { createClient } from '@/utils/supabase/server';

type FetchHistoryResponse = {
data: HistoryInsertData[] | null;
error: PostgrestError | null;
};

export const fetchHistory = async () => {
const supabase = await createClient();

const { data, error }: FetchHistoryResponse = await supabase
.from('history')
.select('*')
.order('timestamp', { ascending: false });

if (error) {
console.error('Error fetching data:', error);

return null;
}

return data;
};
7 changes: 7 additions & 0 deletions src/utils/supabase/history-insert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { HistoryInsertData } from '@/features/history/types/history-data';

import { createClient } from '@/utils/supabase/client';

export const supabase = createClient();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This file creates and exports a singleton supabase client instance. However, the filename history-insert.ts is very specific and doesn't reflect that it's providing a general-purpose client instance. This instance is also used in AuthProvider. To improve code organization and clarity, it's better to define and export this singleton from a more general file, like @/utils/supabase/client.ts. Other parts of the app can then import the singleton from a single, clear source.


export const insertHistory = async (data: HistoryInsertData) => await supabase.from('history').insert(data).single();