Sprint 9: add tenant-driven API resolution layer
This commit is contained in:
@@ -4,6 +4,7 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { cacheInterceptor } from './interceptors/cache.interceptor';
|
import { cacheInterceptor } from './interceptors/cache.interceptor';
|
||||||
|
import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
|
||||||
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
||||||
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
|
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
|
||||||
import { provideServiceWorker } from '@angular/service-worker';
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
@@ -17,7 +18,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
|
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
|
||||||
),
|
),
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
withInterceptors([mockDataInterceptor, apiHeadersInterceptor, cacheInterceptor])
|
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, cacheInterceptor])
|
||||||
),
|
),
|
||||||
provideServiceWorker('ngsw-worker.js', {
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
enabled: !isDevMode(),
|
enabled: !isDevMode(),
|
||||||
|
|||||||
@@ -2,19 +2,18 @@
|
|||||||
import { Component, OnInit, signal, ApplicationRef, inject, DestroyRef } from '@angular/core';
|
import { Component, OnInit, signal, ApplicationRef, inject, DestroyRef } from '@angular/core';
|
||||||
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { HeaderComponent } from './components/header/header.component';
|
import { HeaderComponent } from './components/header/header.component';
|
||||||
import { FooterComponent } from './components/footer/footer.component';
|
import { FooterComponent } from './components/footer/footer.component';
|
||||||
import { BackButtonComponent } from './components/back-button/back-button.component';
|
import { BackButtonComponent } from './components/back-button/back-button.component';
|
||||||
import { interval, concat } from 'rxjs';
|
import { interval, concat } from 'rxjs';
|
||||||
import { filter, first } from 'rxjs/operators';
|
import { filter, first } from 'rxjs/operators';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { environment } from '../environments/environment';
|
|
||||||
import { SwUpdate } from '@angular/service-worker';
|
import { SwUpdate } from '@angular/service-worker';
|
||||||
import { TranslatePipe } from './i18n/translate.pipe';
|
import { TranslatePipe } from './i18n/translate.pipe';
|
||||||
import { TranslateService } from './i18n/translate.service';
|
import { TranslateService } from './i18n/translate.service';
|
||||||
import { PlatformRuntimeService } from './core/runtime/platform-runtime.service';
|
import { PlatformRuntimeService } from './core/runtime/platform-runtime.service';
|
||||||
import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
|
import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
|
||||||
|
import { ApiHealthService } from './services/api-health.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -29,7 +28,6 @@ export class App implements OnInit {
|
|||||||
serverAvailable = signal(false);
|
serverAvailable = signal(false);
|
||||||
|
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
private http = inject(HttpClient);
|
|
||||||
private titleService = inject(Title);
|
private titleService = inject(Title);
|
||||||
private swUpdate = inject(SwUpdate);
|
private swUpdate = inject(SwUpdate);
|
||||||
private appRef = inject(ApplicationRef);
|
private appRef = inject(ApplicationRef);
|
||||||
@@ -37,6 +35,7 @@ export class App implements OnInit {
|
|||||||
private i18n = inject(TranslateService);
|
private i18n = inject(TranslateService);
|
||||||
private platformRuntime = inject(PlatformRuntimeService);
|
private platformRuntime = inject(PlatformRuntimeService);
|
||||||
private uiRuntime = inject(UiRuntimeFacade);
|
private uiRuntime = inject(UiRuntimeFacade);
|
||||||
|
private apiHealth = inject(ApiHealthService);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.platformRuntime.initialize();
|
this.platformRuntime.initialize();
|
||||||
@@ -61,7 +60,7 @@ export class App implements OnInit {
|
|||||||
|
|
||||||
private checkServerHealth(): void {
|
private checkServerHealth(): void {
|
||||||
this.checkingServer.set(true);
|
this.checkingServer.set(true);
|
||||||
this.http.get<{ message: string }>(`${environment.apiUrl}/ping`)
|
this.apiHealth.ping()
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Observable, timer } from 'rxjs';
|
import { Observable, timer } from 'rxjs';
|
||||||
import { retry } from 'rxjs/operators';
|
import { retry } from 'rxjs/operators';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { ApiConfigService } from '../../config/api-config.service';
|
||||||
import { CategoryDto } from '../dto/category.dto';
|
import { CategoryDto } from '../dto/category.dto';
|
||||||
import { CategoryRepository } from './category.repository';
|
import { CategoryRepository } from './category.repository';
|
||||||
|
|
||||||
@@ -13,10 +13,13 @@ export class ApiCategoryRepository implements CategoryRepository {
|
|||||||
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
|
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private readonly http: HttpClient) {}
|
constructor(
|
||||||
|
private readonly http: HttpClient,
|
||||||
|
private readonly apiConfig: ApiConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
getCategories(): Observable<CategoryDto[]> {
|
getCategories(): Observable<CategoryDto[]> {
|
||||||
return this.http.get<CategoryDto[]>(`${environment.apiUrl}/category`)
|
return this.http.get<CategoryDto[]>(`${this.apiConfig.getBaseUrl()}/category`)
|
||||||
.pipe(retry(this.retryConfig));
|
.pipe(retry(this.retryConfig));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
86
src/app/core/config/api-config.service.ts
Normal file
86
src/app/core/config/api-config.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { ConfigService } from './config.service';
|
||||||
|
import { TenantResolverService } from './tenant-resolver.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ApiConfigService {
|
||||||
|
private readonly tenantResolver = inject(TenantResolverService);
|
||||||
|
private readonly configService = inject(ConfigService);
|
||||||
|
|
||||||
|
getBaseUrl(): string {
|
||||||
|
const tenantKey = this.tenantResolver.getTenantKey();
|
||||||
|
const bootstrapUrl = this.resolveBootstrapApiBaseUrl();
|
||||||
|
const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined;
|
||||||
|
const localhostUrl = (environment as any).localhostApiUrl as string | undefined;
|
||||||
|
const apiTemplate = (environment as any).tenantApiTemplate as string | undefined;
|
||||||
|
|
||||||
|
let url = environment.apiUrl;
|
||||||
|
|
||||||
|
if (this.tenantResolver.isLocalhost() && localhostUrl) {
|
||||||
|
url = localhostUrl;
|
||||||
|
} else if (bootstrapUrl) {
|
||||||
|
url = bootstrapUrl;
|
||||||
|
} else if (tenantMap?.[tenantKey]) {
|
||||||
|
url = tenantMap[tenantKey];
|
||||||
|
} else if (apiTemplate) {
|
||||||
|
url = apiTemplate.replace('{tenant}', tenantKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalizeBaseUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
isApiRequest(url: string): boolean {
|
||||||
|
if (!url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith('/api')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = this.getBaseUrl();
|
||||||
|
return url.startsWith(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
toApiUrl(url: string): string {
|
||||||
|
if (!url || /^https?:\/\//i.test(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.startsWith('/api')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = this.getBaseUrl();
|
||||||
|
const path = url.slice('/api'.length);
|
||||||
|
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveBootstrapApiBaseUrl(): string | null {
|
||||||
|
const bootstrap = this.configService.getBootstrapSnapshot() as any;
|
||||||
|
if (!bootstrap) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointBase = bootstrap?.apiEndpoints?.website?.baseUrl;
|
||||||
|
if (typeof endpointBase === 'string' && endpointBase.trim().length > 0) {
|
||||||
|
return endpointBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantBase = bootstrap?.tenant?.apiBaseUrl;
|
||||||
|
if (typeof tenantBase === 'string' && tenantBase.trim().length > 0) {
|
||||||
|
return tenantBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeBaseUrl(url: string): string {
|
||||||
|
if (!url || url === '/') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/app/core/config/tenant-resolver.service.ts
Normal file
36
src/app/core/config/tenant-resolver.service.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TenantResolverService {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
|
getHostname(): string {
|
||||||
|
const host = this.document?.location?.hostname ?? '';
|
||||||
|
return host.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
isLocalhost(): boolean {
|
||||||
|
const hostname = this.getHostname();
|
||||||
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
||||||
|
}
|
||||||
|
|
||||||
|
getTenantKey(): string {
|
||||||
|
if (this.isLocalhost()) {
|
||||||
|
return (environment as any).fallbackTenantKey ?? 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = this.getHostname();
|
||||||
|
const segments = hostname.split('.').filter(Boolean);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return (environment as any).fallbackTenantKey ?? 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments[0] === 'www' && segments.length > 1) {
|
||||||
|
return segments[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app/interceptors/api-base-url.interceptor.ts
Normal file
18
src/app/interceptors/api-base-url.interceptor.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { ApiConfigService } from '../core/config/api-config.service';
|
||||||
|
|
||||||
|
export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const apiConfig = inject(ApiConfigService);
|
||||||
|
|
||||||
|
if (!req.url.startsWith('/api')) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedUrl = apiConfig.toApiUrl(req.url);
|
||||||
|
if (resolvedUrl === req.url) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(req.clone({ url: resolvedUrl }));
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { HttpInterceptorFn } from '@angular/common/http';
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
|
import { ApiConfigService } from '../core/config/api-config.service';
|
||||||
import { LocationService } from '../services/location.service';
|
import { LocationService } from '../services/location.service';
|
||||||
import { LanguageService } from '../services/language.service';
|
import { LanguageService } from '../services/language.service';
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
import { environment } from '../../environments/environment';
|
|
||||||
|
|
||||||
/** Map internal language codes to API header values */
|
/** Map internal language codes to API header values */
|
||||||
const LANG_HEADER_MAP: Record<string, string> = {
|
const LANG_HEADER_MAP: Record<string, string> = {
|
||||||
@@ -39,7 +39,8 @@ function getAnonymousSessionId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
if (!req.url.startsWith(environment.apiUrl)) {
|
const apiConfig = inject(ApiConfigService);
|
||||||
|
if (!apiConfig.isApiRequest(req.url)) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
src/app/services/api-health.service.ts
Normal file
14
src/app/services/api-health.service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiConfigService } from '../core/config/api-config.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ApiHealthService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly apiConfig = inject(ApiConfigService);
|
||||||
|
|
||||||
|
ping(): Observable<{ message: string }> {
|
||||||
|
return this.http.get<{ message: string }>(`${this.apiConfig.getBaseUrl()}/ping`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { map, retry } from 'rxjs/operators';
|
|||||||
import { Category, DeliveryOption, Item, Subcategory } from '../models';
|
import { Category, DeliveryOption, Item, Subcategory } from '../models';
|
||||||
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
|
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
import { ApiConfigService } from '../core/config/api-config.service';
|
||||||
|
|
||||||
export interface QrCreateRequest {
|
export interface QrCreateRequest {
|
||||||
qrtype: 'QRDynamic';
|
qrtype: 'QRDynamic';
|
||||||
@@ -68,7 +69,6 @@ export interface QrDynamicStatusResponse {
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ApiService {
|
export class ApiService {
|
||||||
private readonly baseUrl = environment.apiUrl;
|
|
||||||
private readonly qrBaseUrl = (environment as any).qrApiUrl as string;
|
private readonly qrBaseUrl = (environment as any).qrApiUrl as string;
|
||||||
private readonly cartPaymentPartnerId = 'web-97ec-9c57-4dde-9037-3a68f7f83750';
|
private readonly cartPaymentPartnerId = 'web-97ec-9c57-4dde-9037-3a68f7f83750';
|
||||||
|
|
||||||
@@ -77,7 +77,14 @@ export class ApiService {
|
|||||||
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
|
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(
|
||||||
|
private readonly http: HttpClient,
|
||||||
|
private readonly apiConfig: ApiConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get baseUrl(): string {
|
||||||
|
return this.apiConfig.getBaseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
/** Map API language codes (RU/EN/AM) → frontend codes (ru/en/hy) */
|
/** Map API language codes (RU/EN/AM) → frontend codes (ru/en/hy) */
|
||||||
private normalizeLang(apiLang: string): string {
|
private normalizeLang(apiLang: string): string {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Region, GeoIpResponse } from '../models/location.model';
|
import { Region, GeoIpResponse } from '../models/location.model';
|
||||||
import { environment } from '../../environments/environment';
|
import { ApiConfigService } from '../core/config/api-config.service';
|
||||||
|
|
||||||
const STORAGE_KEY = 'selected_region';
|
const STORAGE_KEY = 'selected_region';
|
||||||
|
|
||||||
@@ -26,16 +26,17 @@ export class LocationService {
|
|||||||
/** Computed region id for API calls — empty string means global */
|
/** Computed region id for API calls — empty string means global */
|
||||||
readonly regionId = computed(() => this.regionSignal()?.id ?? '');
|
readonly regionId = computed(() => this.regionSignal()?.id ?? '');
|
||||||
|
|
||||||
private readonly apiUrl = environment.apiUrl;
|
constructor(
|
||||||
|
private readonly http: HttpClient,
|
||||||
constructor(private http: HttpClient) {
|
private readonly apiConfig: ApiConfigService
|
||||||
|
) {
|
||||||
this.loadRegions();
|
this.loadRegions();
|
||||||
this.restoreFromStorage();
|
this.restoreFromStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch available regions from backend */
|
/** Fetch available regions from backend */
|
||||||
loadRegions(): void {
|
loadRegions(): void {
|
||||||
this.http.get<Region[]>(`${this.apiUrl}/regions`).subscribe({
|
this.http.get<Region[]>(`${this.apiConfig.getBaseUrl()}/regions`).subscribe({
|
||||||
next: (regions) => {
|
next: (regions) => {
|
||||||
this.regionsSignal.set(regions);
|
this.regionsSignal.set(regions);
|
||||||
// If we have a stored region, validate it still exists
|
// If we have a stored region, validate it still exists
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
// Marketplace Production Configuration
|
// Marketplace Production Configuration
|
||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
|
fallbackTenantKey: 'default',
|
||||||
|
localhostApiUrl: 'https://api.dexarmarket.ru:445',
|
||||||
|
tenantApiTemplate: 'https://{tenant}.api.dexarmarket.ru:445',
|
||||||
|
tenantApiBaseUrls: {
|
||||||
|
default: 'https://api.dexarmarket.ru:445',
|
||||||
|
dexarmarket: 'https://api.dexarmarket.ru:445'
|
||||||
|
},
|
||||||
brandName: 'Marketplace',
|
brandName: 'Marketplace',
|
||||||
brandFullName: 'Marketplace',
|
brandFullName: 'Marketplace',
|
||||||
theme: 'dexar',
|
theme: 'dexar',
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
useMockData: false, // Toggle to test with backOffice mock data
|
useMockData: false, // Toggle to test with backOffice mock data
|
||||||
|
fallbackTenantKey: 'default',
|
||||||
|
localhostApiUrl: 'https://api.dexarmarket.ru:445',
|
||||||
|
tenantApiTemplate: 'https://{tenant}.api.dexarmarket.ru:445',
|
||||||
|
tenantApiBaseUrls: {
|
||||||
|
default: 'https://api.dexarmarket.ru:445',
|
||||||
|
dexarmarket: 'https://api.dexarmarket.ru:445'
|
||||||
|
},
|
||||||
brandName: 'Marketplace',
|
brandName: 'Marketplace',
|
||||||
brandFullName: 'Marketplace',
|
brandFullName: 'Marketplace',
|
||||||
theme: 'dexar',
|
theme: 'dexar',
|
||||||
|
|||||||
Reference in New Issue
Block a user