fix(api): route tenants through origin gateway
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
This commit is contained in:
@@ -12,6 +12,7 @@ import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519Ver
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { MediaRepository } from './core/media/media-repository';
|
||||
import { MockMediaRepository } from './core/media/mock-media-repository.service';
|
||||
import { ApiConfigService } from './core/config/api-config.service';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
@@ -27,7 +28,11 @@ export const appConfig: ApplicationConfig = {
|
||||
// other interceptor has run, and normalizes whatever actually came back.
|
||||
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor, apiErrorInterceptor])
|
||||
),
|
||||
{ provide: AUTH_API_URL, useValue: environment.authApiUrl },
|
||||
{
|
||||
provide: AUTH_API_URL,
|
||||
useFactory: (apiConfig: ApiConfigService) => apiConfig.getBaseUrl(),
|
||||
deps: [ApiConfigService]
|
||||
},
|
||||
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
|
||||
// useFactory, not useClass: @marketplaces/auth ships plain tsc output, not
|
||||
// Angular Package Format, so it carries no baked-in Ivy DI metadata for
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ApiConfigService } from '../../config/api-config.service';
|
||||
import { ApiBootstrapProvider } from './api-bootstrap.provider';
|
||||
|
||||
describe('ApiBootstrapProvider', () => {
|
||||
let provider: ApiBootstrapProvider;
|
||||
let httpTesting: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ApiBootstrapProvider,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: ApiConfigService,
|
||||
useValue: { getBaseUrl: () => 'https://gorbushka.market/backend' }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
provider = TestBed.inject(ApiBootstrapProvider);
|
||||
httpTesting = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpTesting.verify());
|
||||
|
||||
it('loads bootstrap from the same tenant API base as every other request', () => {
|
||||
provider.loadBootstrap().subscribe();
|
||||
|
||||
const request = httpTesting.expectOne('https://gorbushka.market/backend/bootstrap');
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush({});
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,16 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { ConfigProvider } from '../../config/config-provider.interface';
|
||||
import { ApiConfigService } from '../../config/api-config.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiBootstrapProvider implements ConfigProvider {
|
||||
private readonly bootstrapUrl = '/bootstrap';
|
||||
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly apiConfig: ApiConfigService
|
||||
) {}
|
||||
|
||||
loadBootstrap(): Observable<BootstrapConfig> {
|
||||
return this.http.get<BootstrapConfig>(this.bootstrapUrl);
|
||||
return this.http.get<BootstrapConfig>(`${this.apiConfig.getBaseUrl()}/bootstrap`);
|
||||
}
|
||||
}
|
||||
|
||||
64
src/app/core/config/api-config.service.spec.ts
Normal file
64
src/app/core/config/api-config.service.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ApiConfigService } from './api-config.service';
|
||||
import { TenantResolverService } from './tenant-resolver.service';
|
||||
|
||||
describe('ApiConfigService', () => {
|
||||
let service: ApiConfigService;
|
||||
let tenantResolver: jasmine.SpyObj<TenantResolverService>;
|
||||
|
||||
beforeEach(() => {
|
||||
tenantResolver = jasmine.createSpyObj<TenantResolverService>(
|
||||
'TenantResolverService',
|
||||
['getHostname', 'getOrigin', 'getTenantKey', 'isLocalhost']
|
||||
);
|
||||
tenantResolver.getTenantKey.and.returnValue('gorbushka');
|
||||
tenantResolver.getOrigin.and.returnValue('https://gorbushka.market');
|
||||
tenantResolver.isLocalhost.and.returnValue(false);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ApiConfigService,
|
||||
{ provide: TenantResolverService, useValue: tenantResolver }
|
||||
]
|
||||
});
|
||||
|
||||
service = TestBed.inject(ApiConfigService);
|
||||
});
|
||||
|
||||
it('uses the current customer hostname for the production API base URL', () => {
|
||||
tenantResolver.getHostname.and.returnValue('gorbushka.market');
|
||||
|
||||
expect(service.getBaseUrl()).toBe('https://gorbushka.market/backend');
|
||||
});
|
||||
|
||||
it('keeps www traffic on the same browser origin', () => {
|
||||
tenantResolver.getHostname.and.returnValue('www.gorbushka.market');
|
||||
tenantResolver.getOrigin.and.returnValue('https://www.gorbushka.market');
|
||||
|
||||
expect(service.getBaseUrl()).toBe('https://www.gorbushka.market/backend');
|
||||
});
|
||||
|
||||
it('preserves the API namespace when targeting a tenant backend', () => {
|
||||
tenantResolver.getHostname.and.returnValue('gorbushka.market');
|
||||
|
||||
expect(service.toApiUrl('/api/v2/storefront/cart'))
|
||||
.toBe('https://gorbushka.market/backend/api/v2/storefront/cart');
|
||||
});
|
||||
|
||||
it('does not duplicate the API prefix for localhost proxy requests', () => {
|
||||
tenantResolver.getHostname.and.returnValue('localhost');
|
||||
tenantResolver.getOrigin.and.returnValue('http://localhost:4200');
|
||||
tenantResolver.getTenantKey.and.returnValue('default');
|
||||
tenantResolver.isLocalhost.and.returnValue(true);
|
||||
|
||||
expect(service.toApiUrl('/api/v2/storefront/cart')).toBe('/api/v2/storefront/cart');
|
||||
});
|
||||
|
||||
it('leaves absolute and non-API URLs unchanged', () => {
|
||||
tenantResolver.getHostname.and.returnValue('gorbushka.market');
|
||||
|
||||
expect(service.toApiUrl('https://cdn.example.com/image.png'))
|
||||
.toBe('https://cdn.example.com/image.png');
|
||||
expect(service.toApiUrl('/assets/config.json')).toBe('/assets/config.json');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,16 @@
|
||||
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 hostname = this.tenantResolver.getHostname();
|
||||
const apiHostname = hostname.startsWith('www.') ? hostname.slice(4) : hostname;
|
||||
const origin = this.tenantResolver.getOrigin();
|
||||
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;
|
||||
@@ -19,13 +19,15 @@ export class ApiConfigService {
|
||||
|
||||
if (this.tenantResolver.isLocalhost() && localhostUrl) {
|
||||
url = localhostUrl;
|
||||
} else if (tenantMap?.[hostname] || tenantMap?.[apiHostname]) {
|
||||
url = tenantMap[hostname] ?? tenantMap[apiHostname];
|
||||
} else if (tenantMap?.[tenantKey]) {
|
||||
url = tenantMap[tenantKey];
|
||||
} else if (apiTemplate) {
|
||||
url = apiTemplate.replace('{tenant}', tenantKey);
|
||||
} else if (bootstrapUrl) {
|
||||
// Bootstrap API override is opt-in and only for absolute URLs.
|
||||
url = bootstrapUrl;
|
||||
} else if (apiTemplate && origin) {
|
||||
url = apiTemplate
|
||||
.replace('{origin}', origin)
|
||||
.replace('{hostname}', apiHostname)
|
||||
.replace('{tenant}', tenantKey);
|
||||
}
|
||||
|
||||
return this.normalizeBaseUrl(url);
|
||||
@@ -54,36 +56,15 @@ export class ApiConfigService {
|
||||
}
|
||||
|
||||
const baseUrl = this.getBaseUrl();
|
||||
const path = url.slice('/api'.length);
|
||||
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
private resolveBootstrapApiBaseUrl(): string | null {
|
||||
const allowBootstrapApiOverride = (environment as any).allowBootstrapApiOverride === true;
|
||||
if (!allowBootstrapApiOverride) {
|
||||
return null;
|
||||
if (baseUrl === '/') {
|
||||
return url;
|
||||
}
|
||||
|
||||
const bootstrap = this.configService.getBootstrapSnapshot() as any;
|
||||
if (!bootstrap) {
|
||||
return null;
|
||||
if (baseUrl === '/api' || baseUrl.endsWith('/api')) {
|
||||
return `${baseUrl}${url.slice('/api'.length)}`;
|
||||
}
|
||||
|
||||
const endpointBase = bootstrap?.apiEndpoints?.website?.baseUrl;
|
||||
if (typeof endpointBase === 'string' && this.isAbsoluteHttpUrl(endpointBase)) {
|
||||
return endpointBase;
|
||||
}
|
||||
|
||||
const tenantBase = bootstrap?.tenant?.apiBaseUrl;
|
||||
if (typeof tenantBase === 'string' && this.isAbsoluteHttpUrl(tenantBase)) {
|
||||
return tenantBase;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private isAbsoluteHttpUrl(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url.trim());
|
||||
return `${baseUrl}${url}`;
|
||||
}
|
||||
|
||||
private normalizeBaseUrl(url: string): string {
|
||||
@@ -93,4 +74,4 @@ export class ApiConfigService {
|
||||
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ export class TenantResolverService {
|
||||
return host.toLowerCase();
|
||||
}
|
||||
|
||||
getOrigin(): string {
|
||||
return this.document?.location?.origin ?? '';
|
||||
}
|
||||
|
||||
isLocalhost(): boolean {
|
||||
const hostname = this.getHostname();
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
||||
@@ -33,4 +37,4 @@ export class TenantResolverService {
|
||||
|
||||
return segments[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,13 @@ export const environment = {
|
||||
production: true,
|
||||
useMockBootstrapOnLocal: false,
|
||||
fallbackTenantKey: 'default',
|
||||
allowBootstrapApiOverride: false,
|
||||
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'
|
||||
},
|
||||
localhostApiUrl: '/api',
|
||||
tenantApiTemplate: '{origin}/backend',
|
||||
tenantApiBaseUrls: {},
|
||||
brandName: 'Marketplace',
|
||||
brandFullName: 'Marketplace',
|
||||
theme: 'dexar',
|
||||
apiUrl: 'https://api.dexarmarket.ru:445',
|
||||
authApiUrl: 'https://api.dexarmarket.ru:445',
|
||||
apiUrl: '/api',
|
||||
qrApiUrl: 'https://qr.vitanova.network/api',
|
||||
logo: '/icons/icon-192x192.png',
|
||||
contactEmail: 'info@dexarmarket.ru',
|
||||
|
||||
@@ -4,18 +4,13 @@ export const environment = {
|
||||
useMockData: false, // Toggle to test with backOffice mock data
|
||||
useMockBootstrapOnLocal: true,
|
||||
fallbackTenantKey: 'default',
|
||||
allowBootstrapApiOverride: false,
|
||||
localhostApiUrl: '/api',
|
||||
tenantApiTemplate: 'https://{tenant}.api.dexarmarket.ru:445',
|
||||
tenantApiBaseUrls: {
|
||||
default: 'https://api.dexarmarket.ru:445',
|
||||
dexarmarket: 'https://api.dexarmarket.ru:445'
|
||||
},
|
||||
tenantApiTemplate: '{origin}/backend',
|
||||
tenantApiBaseUrls: {},
|
||||
brandName: 'Marketplace',
|
||||
brandFullName: 'Marketplace',
|
||||
theme: 'dexar',
|
||||
apiUrl: '/api',
|
||||
authApiUrl: 'https://api.dexarmarket.ru:445',
|
||||
qrApiUrl: 'https://qr.vitanova.network/api',
|
||||
logo: '/icons/icon-192x192.png',
|
||||
contactEmail: 'info@dexarmarket.ru',
|
||||
|
||||
@@ -43,10 +43,6 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Marketplace">
|
||||
|
||||
<!-- Preconnect to API -->
|
||||
<link rel="preconnect" href="https://api.dexarmarket.ru" crossorigin="">
|
||||
<link rel="dns-prefetch" href="https://api.dexarmarket.ru">
|
||||
|
||||
<!-- Preload critical assets for better LCP -->
|
||||
<link rel="preload" href="/icons/icon-192x192.png" as="image" type="image/png">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user