chore: demo app

This commit is contained in:
S. Gromov
2026-08-10 19:13:20 +03:00
parent d0a00fecbc
commit 993dab47df
162 changed files with 12145 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
/** Базовый URL локального Simple API. */
export const SIMPLE_REST_API_BASE_URL =
import.meta.env.VITE_SIMPLE_API_URL ?? 'http://localhost:3001'
/** Максимальное время выполнения одного REST-запроса. */
export const SIMPLE_REST_API_TIMEOUT_MS = 12_000

View File

@@ -0,0 +1,2 @@
export { SimpleRestApiError } from './simple-rest-api.error'
export { toSimpleRestApiError } from './to-simple-rest-api-error'

View File

@@ -0,0 +1,28 @@
/**
* Нормализованная ошибка Simple API для source adapters.
*/
export class SimpleRestApiError extends Error {
/** HTTP-статус ответа, если сервер успел его вернуть. */
readonly status: number | null
/** Стабильный код ошибки внешнего API. */
readonly code: string
/** Идентификатор запроса для диагностики backend-логов. */
readonly requestId: string | null
constructor(params: {
/** HTTP-статус ответа, если сервер успел его вернуть. */
status: number | null
/** Стабильный код ошибки внешнего API. */
code: string
/** Безопасное публичное сообщение backend. */
message: string
/** Идентификатор запроса для диагностики backend-логов. */
requestId: string | null
}) {
super(params.message)
this.name = 'SimpleRestApiError'
this.status = params.status
this.code = params.code
this.requestId = params.requestId
}
}

View File

@@ -0,0 +1,55 @@
import { z } from 'zod'
import { ApiError } from '../generated'
import { SimpleRestApiError } from './simple-rest-api.error'
const errorResponseSchema = z.object({
code: z.string(),
message: z.string(),
requestId: z.string().optional()
})
/**
* Преобразует transport failure в стабильную ошибку REST-модуля.
*/
export const toSimpleRestApiError = (error: unknown): SimpleRestApiError => {
if (error instanceof SimpleRestApiError) {
return error
}
if (error instanceof ApiError) {
const parsedError = errorResponseSchema.safeParse(error.error)
if (parsedError.success) {
return new SimpleRestApiError({
status: error.status,
code: parsedError.data.code,
message: parsedError.data.message,
requestId: parsedError.data.requestId ?? null
})
}
return new SimpleRestApiError({
status: error.status,
code: `HTTP_${error.status}`,
message: error.message,
requestId: null
})
}
if (error instanceof Error) {
return new SimpleRestApiError({
status: null,
code: 'NETWORK_ERROR',
message: error.message,
requestId: null
})
}
return new SimpleRestApiError({
status: null,
code: 'UNKNOWN_ERROR',
message: 'Не удалось выполнить запрос к Simple API.',
requestId: null
})
}

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type { ApiRequestClient } from "./http-client";
export type ApiOperation<TClient extends ApiRequestClient = ApiRequestClient> =
(client: TClient, ...args: any[]) => any;
export type ApiTree<TClient extends ApiRequestClient = ApiRequestClient> = {
readonly [key: string]: ApiOperation<TClient> | ApiTree<TClient>;
};
export type BoundApi<TTree, TClient extends ApiRequestClient> = {
readonly [K in keyof TTree]: TTree[K] extends (
client: TClient,
...args: infer Args
) => infer Result
? (...args: Args) => Result
: TTree[K] extends ApiTree<TClient>
? BoundApi<TTree[K], TClient>
: never;
};
export const createApiClient = <
TClient extends ApiRequestClient,
const TTree extends ApiTree<TClient>,
>(
client: TClient,
tree: TTree,
): BoundApi<TTree, TClient> => {
const bindNode = (
node: ApiOperation<TClient> | ApiTree<TClient>,
): unknown => {
if (typeof node === "function") {
return (...args: unknown[]) => node(client, ...args);
}
return Object.fromEntries(
Object.entries(node).map(([key, value]) => [
key,
bindNode(value as ApiOperation<TClient> | ApiTree<TClient>),
]),
);
};
return bindNode(tree) as BoundApi<TTree, TClient>;
};

View File

@@ -0,0 +1,743 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
export interface HealthDataDto {
/** @example "simple" */
application: HealthDataDtoApplicationEnum;
/** @example "ok" */
status: HealthDataDtoStatusEnum;
/** @format date-time */
timestamp: string;
/** @example "1.0.0" */
version: string;
}
export interface HealthResponseDto {
data: HealthDataDto;
}
export interface LoginDto {
/**
* @format email
* @example "admin@demo.local"
*/
email: string;
/**
* @format password
* @minLength 8
* @example "demo1234"
*/
password: string;
}
export interface JwtTokensDto {
accessToken: string;
refreshToken: string;
/**
* Access-token lifetime in seconds.
* @example 60
*/
expiresIn: number;
/** @example "Bearer" */
tokenType: JwtTokensDtoTokenTypeEnum;
}
export interface SimpleUserDto {
/** @example "user-admin" */
id: string;
/**
* @format email
* @example "admin@demo.local"
*/
email: string;
/** @example "Demo Admin" */
name: string;
role: SimpleUserDtoRoleEnum;
/** @example "https://i.pravatar.cc/160?img=12" */
avatarUrl: object | null;
}
export interface JwtAuthDataDto {
tokens: JwtTokensDto;
user: SimpleUserDto;
}
export interface JwtAuthResponseDto {
data: JwtAuthDataDto;
}
export interface ErrorDetailDto {
/** @example "email" */
field?: string;
/** @example "must be an email" */
message: string;
/** @example "isEmail" */
code?: string;
}
export interface ErrorResponseDto {
/** @example 404 */
statusCode: number;
/** @example "PRODUCT_NOT_FOUND" */
code: string;
/** @example "Product not found" */
message: string;
details: ErrorDetailDto[];
/**
* @format date-time
* @example "2026-07-30T12:00:00.000Z"
*/
timestamp: string;
/** @example "/api/v1/products/product-404" */
path: string;
/** @example "req-5c9f7a3d" */
requestId: string;
}
export interface RefreshTokenDto {
/** Refresh token returned by login or the previous refresh call. */
refreshToken: string;
}
export interface SimpleUserResponseDto {
data: SimpleUserDto;
}
export type Object = object;
export interface SimpleProductDto {
/** @example "product-keyboard" */
id: string;
/** @example "Mechanical Keyboard" */
name: string;
/** @example "mechanical-keyboard" */
slug: string;
/** @example "Hot-swappable compact keyboard." */
description: string;
/**
* Price in the smallest currency unit.
* @example 12990
*/
priceCents: number;
/** @example "USD" */
currency: SimpleProductDtoCurrencyEnum;
/** @example "category-electronics" */
categoryId: string;
/**
* @min 0
* @example 24
*/
stock: number;
/**
* @min 0
* @max 5
* @example 4.8
*/
rating: number;
/**
* @format uri
* @example "https://picsum.photos/seed/keyboard/640/480"
*/
imageUrl: string;
/** @format date-time */
createdAt: string;
/**
* Optimistic-lock version.
* @example 1
*/
version: number;
}
export interface PageMetaDto {
/**
* @min 1
* @example 1
*/
page: number;
/**
* @min 1
* @example 20
*/
limit: number;
/**
* @min 0
* @example 48
*/
total: number;
/**
* @min 0
* @example 3
*/
totalPages: number;
}
export interface ProductsResponseDto {
data: SimpleProductDto[];
meta: PageMetaDto;
}
export interface ProductResponseDto {
data: SimpleProductDto;
}
export interface CreateProductDto {
/** @example "USB-C Dock" */
name: string;
/** @example "Dock with HDMI, Ethernet and power delivery." */
description: string;
/**
* @min 0
* @example 8990
*/
priceCents: number;
/** @example "USD" */
currency: CreateProductDtoCurrencyEnum;
/** @example "category-electronics" */
categoryId: string;
/**
* @min 0
* @example 15
*/
stock: number;
/**
* @format uri
* @example "https://picsum.photos/seed/dock/640/480"
*/
imageUrl: string;
}
export interface UpdateProductDto {
/** @example "USB-C Dock" */
name?: string;
/** @example "Dock with HDMI, Ethernet and power delivery." */
description?: string;
/**
* @min 0
* @example 8990
*/
priceCents?: number;
/** @example "USD" */
currency?: UpdateProductDtoCurrencyEnum;
/** @example "category-electronics" */
categoryId?: string;
/**
* @min 0
* @example 15
*/
stock?: number;
/**
* @format uri
* @example "https://picsum.photos/seed/dock/640/480"
*/
imageUrl?: string;
/**
* Version last read by the frontend.
* @min 1
* @example 1
*/
version: number;
}
export interface MutationResultDto {
/** @example "product-001" */
id: string;
/** @example true */
success: boolean;
}
export interface MutationResponseDto {
data: MutationResultDto;
}
export interface SimpleCategoryDto {
/** @example "category-electronics" */
id: string;
/** @example "Electronics" */
name: string;
/** @example "electronics" */
slug: string;
/** @example 4 */
productCount: number;
}
export interface CategoriesResponseDto {
data: SimpleCategoryDto[];
}
export interface CategoryResponseDto {
data: SimpleCategoryDto;
}
export interface SimpleOrderItemDto {
/** @example "product-keyboard" */
productId: string;
/** @example "Mechanical Keyboard" */
productName: string;
/** @example 1 */
quantity: number;
/** @example 12990 */
unitPriceCents: number;
}
export interface SimpleOrderDto {
/** @example "order-001" */
id: string;
/** @example "user-customer" */
userId: string;
status: SimpleOrderDtoStatusEnum;
items: SimpleOrderItemDto[];
/** @example 17980 */
totalCents: number;
/** @example "USD" */
currency: SimpleOrderDtoCurrencyEnum;
/** @format date-time */
createdAt: string;
}
export interface OrdersResponseDto {
data: SimpleOrderDto[];
meta: PageMetaDto;
}
export interface OrderResponseDto {
data: SimpleOrderDto;
}
export interface CreateOrderItemDto {
/** @example "product-keyboard" */
productId: string;
/**
* @min 1
* @max 20
* @example 1
*/
quantity: number;
/**
* @min 1
* @example 1
*/
expectedVersion: number;
/**
* @min 0
* @example 12990
*/
expectedUnitPriceCents: number;
}
export interface CreateOrderDto {
items: CreateOrderItemDto[];
}
export interface ScenarioDto {
/** @example "slow" */
name: string;
/** @example "Delays the response to exercise loading and cancellation states." */
description: string;
}
export interface ScenariosResponseDto {
data: ScenarioDto[];
}
export interface TestingActionDataDto {
/** @example true */
success: boolean;
/** @example "State reset to the default deterministic seed." */
message: string;
}
export interface TestingActionResponseDto {
data: TestingActionDataDto;
}
export interface ChangeSimpleRoleDto {
/** @example "customer" */
role: ChangeSimpleRoleDtoRoleEnum;
}
/** @example "simple" */
export type HealthDataDtoApplicationEnum = "simple" | "complex";
/** @example "ok" */
export type HealthDataDtoStatusEnum = "ok";
/** @example "Bearer" */
export type JwtTokensDtoTokenTypeEnum = "Bearer";
export type SimpleUserDtoRoleEnum = "admin" | "customer";
/** @example "USD" */
export type SimpleProductDtoCurrencyEnum = "USD" | "EUR";
/** @example "USD" */
export type CreateProductDtoCurrencyEnum = "USD" | "EUR";
/** @example "USD" */
export type UpdateProductDtoCurrencyEnum = "USD" | "EUR";
export type SimpleOrderDtoStatusEnum =
| "pending"
| "paid"
| "shipped"
| "cancelled";
/** @example "USD" */
export type SimpleOrderDtoCurrencyEnum = "USD" | "EUR";
/** @example "customer" */
export type ChangeSimpleRoleDtoRoleEnum = "admin" | "customer";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleHealthHealthParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleAuthLoginParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleAuthRefreshParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleAuthLogoutParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleUsersMeParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleProductsListParams {
/**
* @min 1
* @default 1
*/
page?: Object;
/**
* @min 1
* @max 100
* @default 20
*/
limit?: Object;
/** @example "keyboard" */
search?: string;
/** @example "category-electronics" */
categoryId?: string;
/** @default "newest" */
sort?: SortEnum;
}
/** @default "newest" */
export type SortEnum = "newest" | "price-asc" | "price-desc" | "name";
/** @default "newest" */
export type SimpleProductsListParams1SortEnum =
| "newest"
| "price-asc"
| "price-desc"
| "name";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsListParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsCreateParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleProductsGetParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsGetParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleProductsUpdateParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsUpdateParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleProductsRemoveParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsRemoveParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleCategoriesListParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleCategoriesGetParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleCategoriesGetParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleOrdersListParams {
/**
* @min 1
* @default 1
*/
page?: Object;
/**
* @min 1
* @max 100
* @default 20
*/
limit?: Object;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleOrdersListParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleOrdersCreateParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleOrdersGetParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleOrdersGetParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleOrdersCancelParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleOrdersCancelParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleTestingScenariosParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleTestingResetParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleTestingSeedParams {
preset: PresetEnum;
}
export type PresetEnum = "small" | "large";
export type SimpleTestingSeedParams1PresetEnum = "small" | "large";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleTestingSeedParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export type SimpleTestingSeedParams1Enum = "small" | "large";
export interface SimpleTestingChangeRoleParams {
userId: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleTestingChangeRoleParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";

View File

@@ -0,0 +1,543 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` to mark this request as protected */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
/** request timeout in milliseconds */
timeout?: number;
}
export type RequestParams = Omit<
FullRequestParams,
"body" | "method" | "query" | "path"
>;
export interface RequestContext<TResult = unknown> {
url: string;
request: FullRequestParams;
retryCount: number;
retry: () => Promise<TResult>;
}
export type RequestInterceptor = (
params: FullRequestParams,
context: RequestContext,
) => FullRequestParams | Promise<FullRequestParams>;
export type ResponseInterceptor = <D = unknown, E = unknown>(
response: HttpResponse<D, E>,
context: RequestContext,
) => HttpResponse<D, E> | Promise<HttpResponse<D, E>>;
export type ErrorInterceptor = <TResult = unknown>(
error: unknown,
context: RequestContext<TResult>,
) => TResult | Promise<TResult>;
export type ParamsSerializer = (query: QueryParamsType) => string;
export type ResponseParser = (
response: Response,
format?: ResponseFormat,
) => unknown | Promise<unknown>;
export interface ApiRequestClient {
request<T = any, E = any>(params: FullRequestParams): Promise<T>;
}
export interface ApiConfig<SecurityDataType = unknown>
extends Omit<RequestParams, "baseUrl" | "cancelToken" | "signal"> {
baseUrl?: string;
customFetch?: typeof fetch;
paramsSerializer?: ParamsSerializer;
responseParser?: ResponseParser;
onRequest?: RequestInterceptor;
onResponse?: ResponseInterceptor;
onError?: ErrorInterceptor;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown>
extends Response {
data: D;
error: E;
}
export class ApiError<E = unknown> extends Error {
public readonly status: number;
public readonly statusText: string;
public readonly response: Response;
public readonly data: unknown;
public readonly error: E;
public readonly request: FullRequestParams;
constructor(
response: Response,
request: FullRequestParams,
data: unknown,
error: E,
) {
super(
`Request failed with status ${response.status} ${response.statusText}`.trim(),
);
this.name = "ApiError";
this.status = response.status;
this.statusText = response.statusText;
this.response = response;
this.data = data;
this.error = error;
this.request = request;
}
}
export type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown>
implements ApiRequestClient
{
public baseUrl: string = "http://localhost:3001";
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch: typeof fetch = (...fetchParams) => fetch(...fetchParams);
private paramsSerializer?: ParamsSerializer;
private responseParser?: ResponseParser;
private onRequest?: RequestInterceptor;
private onResponse?: ResponseInterceptor;
private onError?: ErrorInterceptor;
private baseRequestParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
constructor({
baseUrl,
customFetch,
paramsSerializer,
responseParser,
onRequest,
onResponse,
onError,
...baseRequestParams
}: ApiConfig<SecurityDataType> = {}) {
if (typeof baseUrl === "string") {
this.baseUrl = baseUrl;
}
this.customFetch = customFetch || this.customFetch;
this.paramsSerializer = paramsSerializer;
this.responseParser = responseParser;
this.onRequest = onRequest;
this.onResponse = onResponse;
this.onError = onError;
this.baseRequestParams = this.mergeRequestParams(
this.baseRequestParams,
baseRequestParams,
);
}
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
if (this.paramsSerializer) {
return this.paramsSerializer(query);
}
const keys = Object.keys(query).filter(
(key) => "undefined" !== typeof query[key],
);
return keys
.map((key) =>
Array.isArray(query[key])
? this.addArrayQueryParam(query, key)
: this.addQueryParam(query, key),
)
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
protected buildRequestUrl(
baseUrl: string | undefined,
path: string,
query?: QueryParamsType,
): string {
return `${baseUrl || this.baseUrl || ""}${path}${this.addQueryParams(query)}`;
}
protected createRequestContext<TResult>(
request: FullRequestParams,
retryCount: number,
retry: () => Promise<TResult>,
): RequestContext<TResult> {
return {
url: this.buildRequestUrl(request.baseUrl, request.path, request.query),
request,
retryCount,
retry,
};
}
protected updateRequestContext<TResult>(
context: RequestContext<TResult>,
request: FullRequestParams,
) {
context.request = request;
context.url = this.buildRequestUrl(
request.baseUrl,
request.path,
request.query,
);
}
protected mergeHeaders(
...headers: Array<HeadersInit | undefined>
): HeadersInit {
const mergedHeaders = new Headers();
headers.forEach((headers) => {
if (!headers) {
return;
}
new Headers(headers).forEach((value, key) =>
mergedHeaders.set(key, value),
);
});
return Object.fromEntries(mergedHeaders.entries());
}
protected mergeRequestParams<T extends Partial<FullRequestParams>>(
params1: T,
params2?: Partial<FullRequestParams>,
): T {
return {
...params1,
...(params2 || {}),
headers: this.mergeHeaders(params1.headers, params2?.headers),
} as T;
}
protected createAbortSignal = (
cancelToken: CancelToken,
): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
protected createRequestSignal = (
signal?: AbortSignal | null,
cancelToken?: CancelToken,
timeout?: number,
): { signal: AbortSignal | null; cleanup: () => void } => {
const signals: AbortSignal[] = [];
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (signal) {
signals.push(signal);
}
if (cancelToken) {
const cancelSignal = this.createAbortSignal(cancelToken);
if (cancelSignal) {
signals.push(cancelSignal);
}
}
if (typeof timeout === "number" && timeout > 0) {
const timeoutController = new AbortController();
timeoutId = setTimeout(() => timeoutController.abort(), timeout);
signals.push(timeoutController.signal);
}
const cleanupTimeout = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
if (signals.length === 0) {
return { signal: null, cleanup: cleanupTimeout };
}
if (signals.length === 1) {
return { signal: signals[0] || null, cleanup: cleanupTimeout };
}
const abortController = new AbortController();
const abortRequest = () => abortController.abort();
signals.forEach((signal) => {
if (signal.aborted) {
abortController.abort();
} else {
signal.addEventListener("abort", abortRequest, { once: true });
}
});
return {
signal: abortController.signal,
cleanup: () => {
cleanupTimeout();
signals.forEach((signal) =>
signal.removeEventListener("abort", abortRequest),
);
},
};
};
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
};
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.JsonApi]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.Text]: (input: any) =>
input !== null && typeof input !== "string"
? JSON.stringify(input)
: input,
[ContentType.FormData]: (input: any) => {
if (input instanceof FormData) {
return input;
}
return Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob
? property
: typeof property === "object" && property !== null
? JSON.stringify(property)
: `${property}`,
);
return formData;
}, new FormData());
},
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
};
protected parseResponse = async <T = any, E = any>(
response: Response,
responseFormat?: ResponseFormat,
): Promise<HttpResponse<T, E>> => {
const parsedResponse = response as HttpResponse<T, E>;
parsedResponse.data = null as unknown as T;
parsedResponse.error = null as unknown as E;
if (!responseFormat && !this.responseParser) {
return parsedResponse;
}
const responseToParse = response.clone();
await Promise.resolve(
this.responseParser
? this.responseParser(responseToParse, responseFormat)
: responseToParse[responseFormat as ResponseFormat](),
)
.then((data) => {
if (parsedResponse.ok) {
parsedResponse.data = data as T;
} else {
parsedResponse.error = data as E;
}
})
.catch((error) => {
if (parsedResponse.ok) {
throw error;
}
parsedResponse.error = error as E;
});
return parsedResponse;
};
public request = async <T = any, E = any>(
requestParams: FullRequestParams,
) => {
return this.requestWithRetry<T, E>(requestParams, 0);
};
private requestWithRetry = async <T = any, E = any>(
requestParams: FullRequestParams,
retryCount: number,
): Promise<T> => {
let request = this.mergeRequestParams(
this.baseRequestParams,
requestParams,
) as FullRequestParams;
request.baseUrl = request.baseUrl || this.baseUrl;
request.secure =
typeof request.secure === "boolean"
? request.secure
: this.baseRequestParams.secure;
const context = this.createRequestContext<T>(request, retryCount, () =>
this.requestWithRetry<T, E>(requestParams, retryCount + 1),
);
let cleanupSignal = () => {};
let cancelToken: CancelToken | undefined;
const cleanupRequest = () => {
cleanupSignal();
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
};
try {
if (this.onRequest) {
request = await this.onRequest(request, context);
this.updateRequestContext(context, request);
}
const {
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken: requestCancelToken,
timeout,
...params
} = request;
cancelToken = requestCancelToken;
const { signal, cleanup } = this.createRequestSignal(
params.signal,
cancelToken,
timeout,
);
cleanupSignal = cleanup;
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format;
const response = await this.customFetch(context.url, {
...params,
headers: this.mergeHeaders(
params.headers,
type && type !== ContentType.FormData
? { "Content-Type": type }
: undefined,
),
signal,
body:
typeof body === "undefined" || body === null
? null
: payloadFormatter(body),
});
const parsedResponse = await this.parseResponse<T, E>(
response,
responseFormat,
);
if (!parsedResponse.ok) {
throw new ApiError<E>(
parsedResponse,
request,
parsedResponse.error || parsedResponse.data,
parsedResponse.error,
);
}
const finalResponse = this.onResponse
? await this.onResponse<T, E>(parsedResponse, context)
: parsedResponse;
return finalResponse.data;
} catch (error) {
cleanupRequest();
if (this.onError) {
return this.onError(error, context);
}
throw error;
} finally {
cleanupRequest();
}
};
}

View File

@@ -0,0 +1,39 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
export { createApiClient } from "./create-api-client";
export type { ApiOperation, ApiTree, BoundApi } from "./create-api-client";
export type * from "./data-contracts";
export { ApiError, ContentType, HttpClient } from "./http-client";
export type {
ApiConfig,
ApiRequestClient,
ErrorInterceptor,
FullRequestParams,
HttpResponse,
ParamsSerializer,
QueryParamsType,
RequestContext,
RequestInterceptor,
RequestParams,
ResponseFormat,
ResponseInterceptor,
ResponseParser,
} from "./http-client";
export * from "./operations";
export * as operations from "./operations";
export { operationsTree } from "./operations-tree";
export type { OperationsTree } from "./operations-tree";

View File

@@ -0,0 +1,75 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import { simpleAuthLogin } from "./operations/simple-auth-login";
import { simpleAuthLogout } from "./operations/simple-auth-logout";
import { simpleAuthRefresh } from "./operations/simple-auth-refresh";
import { simpleCategoriesGet } from "./operations/simple-categories-get";
import { simpleCategoriesList } from "./operations/simple-categories-list";
import { simpleHealthHealth } from "./operations/simple-health-health";
import { simpleOrdersCancel } from "./operations/simple-orders-cancel";
import { simpleOrdersCreate } from "./operations/simple-orders-create";
import { simpleOrdersGet } from "./operations/simple-orders-get";
import { simpleOrdersList } from "./operations/simple-orders-list";
import { simpleProductsCreate } from "./operations/simple-products-create";
import { simpleProductsGet } from "./operations/simple-products-get";
import { simpleProductsList } from "./operations/simple-products-list";
import { simpleProductsRemove } from "./operations/simple-products-remove";
import { simpleProductsUpdate } from "./operations/simple-products-update";
import { simpleTestingChangeRole } from "./operations/simple-testing-change-role";
import { simpleTestingReset } from "./operations/simple-testing-reset";
import { simpleTestingScenarios } from "./operations/simple-testing-scenarios";
import { simpleTestingSeed } from "./operations/simple-testing-seed";
import { simpleUsersMe } from "./operations/simple-users-me";
export const operationsTree = {
health: {
simpleHealthHealth: simpleHealthHealth,
},
auth: {
simpleAuthLogin: simpleAuthLogin,
simpleAuthRefresh: simpleAuthRefresh,
simpleAuthLogout: simpleAuthLogout,
},
users: {
simpleUsersMe: simpleUsersMe,
},
products: {
simpleProductsList: simpleProductsList,
simpleProductsCreate: simpleProductsCreate,
simpleProductsGet: simpleProductsGet,
simpleProductsUpdate: simpleProductsUpdate,
simpleProductsRemove: simpleProductsRemove,
},
categories: {
simpleCategoriesList: simpleCategoriesList,
simpleCategoriesGet: simpleCategoriesGet,
},
orders: {
simpleOrdersList: simpleOrdersList,
simpleOrdersCreate: simpleOrdersCreate,
simpleOrdersGet: simpleOrdersGet,
simpleOrdersCancel: simpleOrdersCancel,
},
testing: {
simpleTestingScenarios: simpleTestingScenarios,
simpleTestingReset: simpleTestingReset,
simpleTestingSeed: simpleTestingSeed,
simpleTestingChangeRole: simpleTestingChangeRole,
},
} as const;
export type OperationsTree = typeof operationsTree;

View File

@@ -0,0 +1,36 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
export { simpleAuthLogin } from "./simple-auth-login";
export { simpleAuthLogout } from "./simple-auth-logout";
export { simpleAuthRefresh } from "./simple-auth-refresh";
export { simpleCategoriesGet } from "./simple-categories-get";
export { simpleCategoriesList } from "./simple-categories-list";
export { simpleHealthHealth } from "./simple-health-health";
export { simpleOrdersCancel } from "./simple-orders-cancel";
export { simpleOrdersCreate } from "./simple-orders-create";
export { simpleOrdersGet } from "./simple-orders-get";
export { simpleOrdersList } from "./simple-orders-list";
export { simpleProductsCreate } from "./simple-products-create";
export { simpleProductsGet } from "./simple-products-get";
export { simpleProductsList } from "./simple-products-list";
export { simpleProductsRemove } from "./simple-products-remove";
export { simpleProductsUpdate } from "./simple-products-update";
export { simpleTestingChangeRole } from "./simple-testing-change-role";
export { simpleTestingReset } from "./simple-testing-reset";
export { simpleTestingScenarios } from "./simple-testing-scenarios";
export { simpleTestingSeed } from "./simple-testing-seed";
export { simpleUsersMe } from "./simple-users-me";

View File

@@ -0,0 +1,45 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
JwtAuthResponseDto,
LoginDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Auth
* @name SimpleAuthLogin
* @summary Login and receive JWT access/refresh tokens
* @request POST:/api/v1/auth/login
*/
export const simpleAuthLogin = (
http: ApiRequestClient,
data: LoginDto,
requestParams: RequestParams = {},
) =>
http.request<JwtAuthResponseDto, ErrorResponseDto>({
path: `/api/v1/auth/login`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,40 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type { ErrorResponseDto, RefreshTokenDto } from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Auth
* @name SimpleAuthLogout
* @summary Revoke a refresh token; the operation is idempotent
* @request POST:/api/v1/auth/logout
*/
export const simpleAuthLogout = (
http: ApiRequestClient,
data: RefreshTokenDto,
requestParams: RequestParams = {},
) =>
http.request<void, ErrorResponseDto>({
path: `/api/v1/auth/logout`,
method: "POST",
body: data,
type: ContentType.Json,
...requestParams,
});

View File

@@ -0,0 +1,45 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
JwtAuthResponseDto,
RefreshTokenDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Auth
* @name SimpleAuthRefresh
* @summary Rotate a refresh token and issue a new token pair
* @request POST:/api/v1/auth/refresh
*/
export const simpleAuthRefresh = (
http: ApiRequestClient,
data: RefreshTokenDto,
requestParams: RequestParams = {},
) =>
http.request<JwtAuthResponseDto, ErrorResponseDto>({
path: `/api/v1/auth/refresh`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,42 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
CategoryResponseDto,
ErrorResponseDto,
SimpleCategoriesGetParams,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Categories
* @name SimpleCategoriesGet
* @summary Get one category
* @request GET:/api/v1/categories/{id}
*/
export const simpleCategoriesGet = (
http: ApiRequestClient,
{ id, ...query }: SimpleCategoriesGetParams,
requestParams: RequestParams = {},
) =>
http.request<CategoryResponseDto, ErrorResponseDto>({
path: `/api/v1/categories/${id}`,
method: "GET",
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,37 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type { CategoriesResponseDto } from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Categories
* @name SimpleCategoriesList
* @summary List product categories
* @request GET:/api/v1/categories
*/
export const simpleCategoriesList = (
http: ApiRequestClient,
requestParams: RequestParams = {},
) =>
http.request<CategoriesResponseDto, any>({
path: `/api/v1/categories`,
method: "GET",
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,37 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type { HealthResponseDto } from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Health
* @name SimpleHealthHealth
* @summary Check Simple API availability
* @request GET:/api/v1/health
*/
export const simpleHealthHealth = (
http: ApiRequestClient,
requestParams: RequestParams = {},
) =>
http.request<HealthResponseDto, any>({
path: `/api/v1/health`,
method: "GET",
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
OrderResponseDto,
SimpleOrdersCancelParams,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Orders
* @name SimpleOrdersCancel
* @summary Cancel an order if its state permits the transition
* @request POST:/api/v1/orders/{id}/cancel
* @secure
*/
export const simpleOrdersCancel = (
http: ApiRequestClient,
{ id, ...query }: SimpleOrdersCancelParams,
requestParams: RequestParams = {},
) =>
http.request<OrderResponseDto, ErrorResponseDto>({
path: `/api/v1/orders/${id}/cancel`,
method: "POST",
secure: true,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,47 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
CreateOrderDto,
ErrorResponseDto,
OrderResponseDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Orders
* @name SimpleOrdersCreate
* @summary Create an order and validate product stock
* @request POST:/api/v1/orders
* @secure
*/
export const simpleOrdersCreate = (
http: ApiRequestClient,
data: CreateOrderDto,
requestParams: RequestParams = {},
) =>
http.request<OrderResponseDto, ErrorResponseDto>({
path: `/api/v1/orders`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
OrderResponseDto,
SimpleOrdersGetParams,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Orders
* @name SimpleOrdersGet
* @summary Get one visible order
* @request GET:/api/v1/orders/{id}
* @secure
*/
export const simpleOrdersGet = (
http: ApiRequestClient,
{ id, ...query }: SimpleOrdersGetParams,
requestParams: RequestParams = {},
) =>
http.request<OrderResponseDto, ErrorResponseDto>({
path: `/api/v1/orders/${id}`,
method: "GET",
secure: true,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,45 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
OrdersResponseDto,
SimpleOrdersListParams,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Orders
* @name SimpleOrdersList
* @summary List orders visible to the current user
* @request GET:/api/v1/orders
* @secure
*/
export const simpleOrdersList = (
http: ApiRequestClient,
query: SimpleOrdersListParams,
requestParams: RequestParams = {},
) =>
http.request<OrdersResponseDto, ErrorResponseDto>({
path: `/api/v1/orders`,
method: "GET",
query: query,
secure: true,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,47 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
CreateProductDto,
ErrorResponseDto,
ProductResponseDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Products
* @name SimpleProductsCreate
* @summary Create a product as an administrator
* @request POST:/api/v1/products
* @secure
*/
export const simpleProductsCreate = (
http: ApiRequestClient,
data: CreateProductDto,
requestParams: RequestParams = {},
) =>
http.request<ProductResponseDto, ErrorResponseDto>({
path: `/api/v1/products`,
method: "POST",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,42 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
ProductResponseDto,
SimpleProductsGetParams,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Products
* @name SimpleProductsGet
* @summary Get one product with ETag support
* @request GET:/api/v1/products/{id}
*/
export const simpleProductsGet = (
http: ApiRequestClient,
{ id, ...query }: SimpleProductsGetParams,
requestParams: RequestParams = {},
) =>
http.request<ProductResponseDto, void | ErrorResponseDto>({
path: `/api/v1/products/${id}`,
method: "GET",
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,43 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
ProductsResponseDto,
SimpleProductsListParams,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Products
* @name SimpleProductsList
* @summary List products using offset pagination, filters and sorting
* @request GET:/api/v1/products
*/
export const simpleProductsList = (
http: ApiRequestClient,
query: SimpleProductsListParams,
requestParams: RequestParams = {},
) =>
http.request<ProductsResponseDto, ErrorResponseDto>({
path: `/api/v1/products`,
method: "GET",
query: query,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
MutationResponseDto,
SimpleProductsRemoveParams,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Products
* @name SimpleProductsRemove
* @summary Delete a product as an administrator
* @request DELETE:/api/v1/products/{id}
* @secure
*/
export const simpleProductsRemove = (
http: ApiRequestClient,
{ id, ...query }: SimpleProductsRemoveParams,
requestParams: RequestParams = {},
) =>
http.request<MutationResponseDto, ErrorResponseDto>({
path: `/api/v1/products/${id}`,
method: "DELETE",
secure: true,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,49 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
ProductResponseDto,
SimpleProductsUpdateParams,
UpdateProductDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Products
* @name SimpleProductsUpdate
* @summary Update a product using optimistic locking
* @request PATCH:/api/v1/products/{id}
* @secure
*/
export const simpleProductsUpdate = (
http: ApiRequestClient,
{ id, ...query }: SimpleProductsUpdateParams,
data: UpdateProductDto,
requestParams: RequestParams = {},
) =>
http.request<ProductResponseDto, ErrorResponseDto>({
path: `/api/v1/products/${id}`,
method: "PATCH",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,47 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ChangeSimpleRoleDto,
ErrorResponseDto,
SimpleTestingChangeRoleParams,
SimpleUserResponseDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Testing
* @name SimpleTestingChangeRole
* @summary Change a user role to exercise dynamic access control
* @request POST:/api/v1/testing/users/{userId}/role
*/
export const simpleTestingChangeRole = (
http: ApiRequestClient,
{ userId, ...query }: SimpleTestingChangeRoleParams,
data: ChangeSimpleRoleDto,
requestParams: RequestParams = {},
) =>
http.request<SimpleUserResponseDto, ErrorResponseDto>({
path: `/api/v1/testing/users/${userId}/role`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,37 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type { TestingActionResponseDto } from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Testing
* @name SimpleTestingReset
* @summary Reset all Simple API state and token revocations
* @request POST:/api/v1/testing/reset
*/
export const simpleTestingReset = (
http: ApiRequestClient,
requestParams: RequestParams = {},
) =>
http.request<TestingActionResponseDto, any>({
path: `/api/v1/testing/reset`,
method: "POST",
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,37 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type { ScenariosResponseDto } from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Testing
* @name SimpleTestingScenarios
* @summary List deterministic X-Demo-Scenario values
* @request GET:/api/v1/testing/scenarios
*/
export const simpleTestingScenarios = (
http: ApiRequestClient,
requestParams: RequestParams = {},
) =>
http.request<ScenariosResponseDto, any>({
path: `/api/v1/testing/scenarios`,
method: "GET",
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,41 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
SimpleTestingSeedParams,
TestingActionResponseDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Testing
* @name SimpleTestingSeed
* @summary Select a small or large deterministic dataset
* @request POST:/api/v1/testing/seed/{preset}
*/
export const simpleTestingSeed = (
http: ApiRequestClient,
{ preset, ...query }: SimpleTestingSeedParams,
requestParams: RequestParams = {},
) =>
http.request<TestingActionResponseDto, any>({
path: `/api/v1/testing/seed/${preset}`,
method: "POST",
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,42 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
SimpleUserResponseDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
/**
* No description
*
* @tags Users
* @name SimpleUsersMe
* @summary Get the authenticated user
* @request GET:/api/v1/users/me
* @secure
*/
export const simpleUsersMe = (
http: ApiRequestClient,
requestParams: RequestParams = {},
) =>
http.request<SimpleUserResponseDto, ErrorResponseDto>({
path: `/api/v1/users/me`,
method: "GET",
secure: true,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,5 @@
'use client'
export { getCategoryListKey, useGetCategoryList } from './use-get-category-list.hook'
export { getOrderListKey, useGetOrderList } from './use-get-order-list.hook'
export { getProductListKey, useGetProductList } from './use-get-product-list.hook'

View File

@@ -0,0 +1,20 @@
type QueryValue = boolean | number | string | null | undefined
/**
* Собирает стабильную query-строку для SWR cache key.
*/
export const createQueryString = (query: Record<string, QueryValue>): string => {
const searchParams = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === null || value === undefined || value === '') {
return
}
searchParams.set(key, String(value))
})
const search = searchParams.toString()
return search ? `?${search}` : ''
}

View File

@@ -0,0 +1,26 @@
import useSWR from 'swr'
import type { SWRConfiguration } from 'swr'
import type { CategoriesResponseDto } from '../generated'
import { simpleCategoriesList } from '../generated/operations/simple-categories-list'
import { simpleHttpClient } from '../transport/client'
/**
* Возвращает SWR-ключ списка категорий.
*/
export const getCategoryListKey = () => {
return ['simple-rest-api', '/api/v1/categories'] as const
}
/**
* Получает категории каталога с прозрачным SWR-кешированием.
*/
export const useGetCategoryList = (
config?: SWRConfiguration<CategoriesResponseDto>
) => {
return useSWR<CategoriesResponseDto>(
getCategoryListKey(),
() => simpleCategoriesList(simpleHttpClient),
config
)
}

View File

@@ -0,0 +1,33 @@
import useSWR from 'swr'
import type { SWRConfiguration } from 'swr'
import type { OrdersResponseDto, SimpleOrdersListParams } from '../generated'
import { simpleOrdersList } from '../generated/operations/simple-orders-list'
import { simpleHttpClient } from '../transport/client'
import type { GetOrdersParams } from '../types'
import { createQueryString } from './lib/create-query-string'
/**
* Возвращает SWR-ключ списка заказов с фактическим endpoint.
*/
export const getOrderListKey = (params: GetOrdersParams = {}) => {
const query = createQueryString(params)
return ['simple-rest-api', `/api/v1/orders${query}`] as const
}
/**
* Получает страницу доступных пользователю заказов.
*/
export const useGetOrderList = (
params: GetOrdersParams = {},
config?: SWRConfiguration<OrdersResponseDto>
) => {
const fetcher = () => {
// Backend schema types page and limit as Object although the wire values are numbers.
const generatedParams = params as unknown as SimpleOrdersListParams
return simpleOrdersList(simpleHttpClient, generatedParams)
}
return useSWR<OrdersResponseDto>(getOrderListKey(params), fetcher, config)
}

View File

@@ -0,0 +1,33 @@
import useSWR from 'swr'
import type { SWRConfiguration } from 'swr'
import type { ProductsResponseDto, SimpleProductsListParams } from '../generated'
import { simpleProductsList } from '../generated/operations/simple-products-list'
import { simpleHttpClient } from '../transport/client'
import type { GetProductsParams } from '../types'
import { createQueryString } from './lib/create-query-string'
/**
* Возвращает SWR-ключ списка продуктов с фактическим endpoint.
*/
export const getProductListKey = (params: GetProductsParams = {}) => {
const query = createQueryString(params)
return ['simple-rest-api', `/api/v1/products${query}`] as const
}
/**
* Получает страницу продуктов с прозрачным SWR-кешированием.
*/
export const useGetProductList = (
params: GetProductsParams = {},
config?: SWRConfiguration<ProductsResponseDto>
) => {
const fetcher = () => {
// Backend schema types page and limit as Object although the wire values are numbers.
const generatedParams = params as unknown as SimpleProductsListParams
return simpleProductsList(simpleHttpClient, generatedParams)
}
return useSWR<ProductsResponseDto>(getProductListKey(params), fetcher, config)
}

View File

@@ -0,0 +1,10 @@
export { toSimpleRestApiError } from './errors'
export { simpleRestApi } from './rest-api'
export {
clearSimpleRestApiTokens,
getSimpleRestApiRefreshToken,
hasSimpleRestApiRefreshToken,
setSimpleRestApiTokens
} from './session/simple-rest-api-credentials'
export { subscribeSimpleRestApiSessionExpired } from './session/simple-rest-api-session-events'
export * from './hooks'

View File

@@ -0,0 +1,5 @@
import { createApiClient, operationsTree } from './generated'
import { simpleHttpClient } from './transport/client'
/** Полный bound-клиент Simple REST API для private source adapters. */
export const simpleRestApi = createApiClient(simpleHttpClient, operationsTree)

View File

@@ -0,0 +1,53 @@
import type { JwtTokensDto } from '../generated'
const REFRESH_TOKEN_STORAGE_KEY = 'slm-store.refresh-token'
let accessToken: string | null = null
/**
* Возвращает sessionStorage только в browser runtime.
*/
const getSessionStorage = (): Storage | null => {
if (typeof window === 'undefined') {
return null
}
return window.sessionStorage
}
/**
* Сохраняет новую пару JWT после входа или ротации refresh token.
*/
export const setSimpleRestApiTokens = (tokens: JwtTokensDto): void => {
accessToken = tokens.accessToken
getSessionStorage()?.setItem(REFRESH_TOKEN_STORAGE_KEY, tokens.refreshToken)
}
/**
* Возвращает access token текущей browser-сессии.
*/
export const getSimpleRestApiAccessToken = (): string | null => {
return accessToken
}
/**
* Возвращает refresh token, переживающий перезагрузку текущей вкладки.
*/
export const getSimpleRestApiRefreshToken = (): string | null => {
return getSessionStorage()?.getItem(REFRESH_TOKEN_STORAGE_KEY) ?? null
}
/**
* Проверяет, можно ли восстановить пользовательскую сессию.
*/
export const hasSimpleRestApiRefreshToken = (): boolean => {
return getSimpleRestApiRefreshToken() !== null
}
/**
* Удаляет transport credentials при выходе или истечении сессии.
*/
export const clearSimpleRestApiTokens = (): void => {
accessToken = null
getSessionStorage()?.removeItem(REFRESH_TOKEN_STORAGE_KEY)
}

View File

@@ -0,0 +1,24 @@
/** Обработчик окончательного истечения REST-сессии. */
export type SimpleRestApiSessionExpiredListener = () => void
const listeners = new Set<SimpleRestApiSessionExpiredListener>()
/**
* Подписывает владельца пользовательской сессии на потерю credentials.
*/
export const subscribeSimpleRestApiSessionExpired = (
listener: SimpleRestApiSessionExpiredListener
): (() => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/**
* Сообщает подписчикам, что refresh token больше нельзя использовать.
*/
export const notifySimpleRestApiSessionExpired = (): void => {
listeners.forEach((listener) => listener())
}

View File

@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { simpleUsersMe } from '../generated/operations/simple-users-me'
import {
clearSimpleRestApiTokens,
setSimpleRestApiTokens
} from '../session/simple-rest-api-credentials'
import { simpleHttpClient } from './client'
/**
* Создаёт JSON response для transport-level проверки.
*/
const jsonResponse = (body: unknown, status = 200): Response => {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
})
}
afterEach(() => {
clearSimpleRestApiTokens()
})
describe('simpleHttpClient', () => {
it('coalesces concurrent 401 responses into one refresh request', async () => {
let releaseRefresh = (): void => undefined
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve
})
let refreshRequestCount = 0
let expiredRequestCount = 0
/**
* Эмулирует истёкший access token и управляемую ротацию token pair.
*/
const fetchMock = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = new URL(String(input))
const authorization = new Headers(init?.headers).get('Authorization')
if (url.pathname === '/api/v1/auth/refresh') {
refreshRequestCount += 1
await refreshGate
return jsonResponse({
data: {
tokens: {
accessToken: 'fresh-access',
refreshToken: 'fresh-refresh',
expiresIn: 60,
tokenType: 'Bearer'
},
user: {
id: 'user-admin',
email: 'admin@demo.local',
name: 'Demo Admin',
role: 'admin',
avatarUrl: null
}
}
})
}
if (authorization === 'Bearer expired-access') {
expiredRequestCount += 1
return jsonResponse({ code: 'JWT_INVALID', message: 'Expired' }, 401)
}
return jsonResponse({
data: {
id: 'user-admin',
email: 'admin@demo.local',
name: 'Demo Admin',
role: 'admin',
avatarUrl: null
}
})
}
)
vi.stubGlobal('fetch', fetchMock)
setSimpleRestApiTokens({
accessToken: 'expired-access',
refreshToken: 'refresh-token',
expiresIn: 0,
tokenType: 'Bearer'
})
const requests = Promise.all([
simpleUsersMe(simpleHttpClient),
simpleUsersMe(simpleHttpClient)
])
await vi.waitFor(() => {
expect(expiredRequestCount).toBe(2)
expect(refreshRequestCount).toBe(1)
})
releaseRefresh()
const responses = await requests
expect(responses).toHaveLength(2)
expect(responses[0].data.email).toBe('admin@demo.local')
expect(refreshRequestCount).toBe(1)
})
})

View File

@@ -0,0 +1,84 @@
import { ApiError, HttpClient } from '../generated'
import { simpleAuthRefresh } from '../generated/operations/simple-auth-refresh'
import {
SIMPLE_REST_API_BASE_URL,
SIMPLE_REST_API_TIMEOUT_MS
} from '../config/simple-rest-api.config'
import {
clearSimpleRestApiTokens,
getSimpleRestApiAccessToken,
getSimpleRestApiRefreshToken,
setSimpleRestApiTokens
} from '../session/simple-rest-api-credentials'
import { notifySimpleRestApiSessionExpired } from '../session/simple-rest-api-session-events'
const refreshHttpClient = new HttpClient({
baseUrl: SIMPLE_REST_API_BASE_URL,
timeout: SIMPLE_REST_API_TIMEOUT_MS
})
let refreshPromise: Promise<void> | null = null
/**
* Ротирует refresh token и объединяет конкурентные 401 в один запрос.
*/
const refreshSimpleRestApiSession = async (): Promise<void> => {
const refreshToken = getSimpleRestApiRefreshToken()
if (!refreshToken) {
throw new Error('Refresh token is missing')
}
if (!refreshPromise) {
refreshPromise = simpleAuthRefresh(refreshHttpClient, { refreshToken })
.then((response) => {
setSimpleRestApiTokens(response.data.tokens)
})
.finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
/** Транспортный HTTP-клиент Simple REST API. */
export const simpleHttpClient = new HttpClient({
baseUrl: SIMPLE_REST_API_BASE_URL,
timeout: SIMPLE_REST_API_TIMEOUT_MS,
onRequest: (params) => {
const token = getSimpleRestApiAccessToken()
if (!params.secure || !token) {
return params
}
const headers = new Headers(params.headers)
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
return { ...params, headers }
},
onError: async (error, context) => {
const shouldRefresh =
error instanceof ApiError &&
error.status === 401 &&
context.request.secure === true &&
context.retryCount === 0
if (!shouldRefresh) {
throw error
}
try {
await refreshSimpleRestApiSession()
return context.retry()
} catch (refreshError) {
clearSimpleRestApiTokens()
notifySimpleRestApiSessionExpired()
throw refreshError
}
}
})

View File

@@ -0,0 +1,11 @@
/**
* Исправленные параметры списка заказов.
*
* OpenAPI backend ошибочно описывает page и limit через пустой Object schema.
*/
export type GetOrdersParams = {
/** Номер страницы, начиная с единицы. */
page?: number
/** Число заказов на странице. */
limit?: number
}

View File

@@ -0,0 +1,19 @@
import type { SortEnum } from '../generated'
/**
* Исправленные параметры списка продуктов.
*
* OpenAPI backend ошибочно описывает page и limit через пустой Object schema.
*/
export type GetProductsParams = {
/** Номер страницы, начиная с единицы. */
page?: number
/** Число продуктов на странице. */
limit?: number
/** Поиск по имени и описанию. */
search?: string
/** Фильтр по идентификатору категории. */
categoryId?: string
/** Порядок сортировки каталога. */
sort?: SortEnum
}

View File

@@ -0,0 +1,2 @@
export type { GetOrdersParams } from './get-orders-params.type'
export type { GetProductsParams } from './get-products-params.type'