Skip to content

Commit 8bc4d3f

Browse files
authored
Merge pull request #694 from namecheap/feat/add_domainField_to_apps
feat: add domainAlias field
2 parents ee5d73e + 98b7db6 commit 8bc4d3f

File tree

8 files changed

+315
-15
lines changed

8 files changed

+315
-15
lines changed

docs/multi-domains.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,20 @@ When creating or updating a route via the API, you can supply `domainAlias` inst
213213

214214
The Registry resolves the alias to the corresponding `domainId` at write time. If no router domain with that alias exists, the request is rejected with a validation error.
215215

216+
### Using `domainAlias` in apps
217+
218+
When creating or updating an app via the API, or when upserting config via `/api/v1/config`, you can supply `domainAlias` instead of `enforceDomain`. The two fields are mutually exclusive — provide exactly one or neither.
219+
220+
```json
221+
{
222+
"name": "@portal/checkout-embedded",
223+
"domainAlias": "main-shop",
224+
"spaBundle": "https://cdn.example.com/checkout.js"
225+
}
226+
```
227+
228+
The Registry resolves the alias to the corresponding router domain at write time and stores it in the existing `enforceDomain` relation. This is especially useful for embedded applications that do not have their own route: the app is included only in the matching domain config, so it cannot be loaded from another domain through the ILC client APIs.
229+
216230
!!! note ""
217231
The alias must be unique across all router domains within an ILC instance.
218232

registry/server/appRoutes/routes/RoutesService.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,24 @@ import { Tables } from '../../db/structure';
55
import { extractInsertedId, isUniqueConstraintError } from '../../util/db';
66
import { appendDigest } from '../../util/hmac';
77
import { EntityTypes, VersionedRecord } from '../../versioning/interfaces';
8-
import { AppRoute, AppRouteDto, appRouteSchema, AppRouteSlot } from '../interfaces';
8+
import { AppRoute, appRouteSchema, AppRouteSlot } from '../interfaces';
99
import { prepareAppRouteSlotsToSave, prepareAppRouteToSave } from '../services/prepareAppRoute';
1010
import { resolveDomainAlias } from '../services/resolveDomainAlias';
1111

1212
export type AppRouteWithSlot = VersionedRecord<AppRoute & AppRouteSlot>;
1313

14+
type ExistingRouteLookup = {
15+
route: string;
16+
domainId: number | null;
17+
namespace: string | null;
18+
};
19+
20+
type ExistingRouteByOrderPosLookup = {
21+
orderPos?: number | null;
22+
domainId: number | null;
23+
namespace: string | null;
24+
};
25+
1426
export class RoutesService {
1527
constructor(private readonly db: VersionedKnex) {}
1628

@@ -127,7 +139,7 @@ export class RoutesService {
127139
}
128140

129141
private async findExistingOrderPos(
130-
appRoute: Omit<AppRouteDto, 'slots'>,
142+
appRoute: ExistingRouteLookup,
131143
trx: Knex.Transaction,
132144
): Promise<number | undefined | null> {
133145
const existingRoute = await this.db(Tables.Routes)
@@ -138,7 +150,7 @@ export class RoutesService {
138150
}
139151

140152
private async findExistingRouteId(
141-
appRoute: Omit<AppRouteDto, 'slots' | 'meta'>,
153+
appRoute: ExistingRouteByOrderPosLookup,
142154
trx: Knex.Transaction,
143155
): Promise<number | undefined> {
144156
const existingRoute = await this.db(Tables.Routes)

registry/server/appRoutes/services/resolveDomainAlias.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,28 @@ import { getJoiErr } from '../../util/helpers';
66
* Returns the route data with `domainId` set and `domainAlias` removed.
77
* Throws a Joi-style error if the alias doesn't match any router domain.
88
*/
9-
export async function resolveDomainAlias<T extends { domainId?: number | null; domainAlias?: string | null }>(
9+
type AppRouteWithDomainAlias = {
10+
domainId?: number | null;
11+
domainAlias?: string | null;
12+
};
13+
14+
type ResolvedRouteDomainAlias<T extends AppRouteWithDomainAlias> = Omit<T, 'domainAlias' | 'domainId'> & {
15+
domainId: number | null;
16+
};
17+
18+
export async function resolveDomainAlias<T extends AppRouteWithDomainAlias>(
1019
appRoute: T,
11-
): Promise<T> {
20+
): Promise<ResolvedRouteDomainAlias<T>> {
1221
const { domainAlias, domainId, ...rest } = appRoute;
1322

1423
if (!domainAlias) {
15-
return { ...rest, domainId: domainId ?? null } as T;
24+
return { ...rest, domainId: domainId ?? null } as ResolvedRouteDomainAlias<T>;
1625
}
1726

1827
const domain = await db('router_domains').first('id').where({ alias: domainAlias });
1928
if (!domain) {
2029
throw getJoiErr('domainAlias', `Router domain with alias "${domainAlias}" does not exist`);
2130
}
2231

23-
return { ...rest, domainId: domain.id } as T;
32+
return { ...rest, domainId: domain.id } as ResolvedRouteDomainAlias<T>;
2433
}

registry/server/apps/interfaces/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface App {
2222
discoveryMetadata?: string | null; // JSON({ [propName: string]: any })
2323
adminNotes?: string | null;
2424
enforceDomain?: number | null;
25+
domainAlias?: string | null;
2526
l10nManifest?: string | null;
2627
namespace?: string | null;
2728
}
@@ -83,17 +84,30 @@ const commonApp = {
8384
discoveryMetadata: Joi.object().default({}),
8485
adminNotes: Joi.string().trim(),
8586
enforceDomain: Joi.number().default(null),
87+
domainAlias: Joi.string()
88+
.lowercase()
89+
.pattern(/^[a-z0-9-]+$/)
90+
.max(64)
91+
.trim(),
8692
l10nManifest: Joi.string().max(255),
8793
versionId: Joi.string().strip(),
8894
namespace: Joi.string().default(null),
8995
};
9096

97+
const validateDomainReference = (value: App, helpers: JoiDefault.CustomHelpers<App>) => {
98+
if (value.enforceDomain != null && value.domainAlias != null) {
99+
return helpers.error('object.oxor', { peers: ['enforceDomain', 'domainAlias'] });
100+
}
101+
102+
return value;
103+
};
104+
91105
export const partialAppSchema = Joi.object<App>({
92106
...commonApp,
93107
name: appNameSchema.forbidden(),
94-
});
108+
}).custom(validateDomainReference);
95109

96110
export const appSchema = Joi.object<App>({
97111
...commonApp,
98112
name: appNameSchema.required(),
99-
});
113+
}).custom(validateDomainReference);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import db from '../../db';
2+
import { getJoiErr } from '../../util/helpers';
3+
4+
type AppWithDomainAlias = {
5+
enforceDomain?: number | null;
6+
domainAlias?: string | null;
7+
};
8+
9+
type ResolvedAppDomainAlias<T extends AppWithDomainAlias> = Omit<T, 'domainAlias'>;
10+
11+
export async function resolveDomainAlias<T extends AppWithDomainAlias>(app: T): Promise<ResolvedAppDomainAlias<T>> {
12+
const { domainAlias, enforceDomain, ...rest } = app;
13+
14+
if (!domainAlias) {
15+
if (enforceDomain === undefined) {
16+
return rest as ResolvedAppDomainAlias<T>;
17+
}
18+
19+
return { ...rest, enforceDomain } as ResolvedAppDomainAlias<T>;
20+
}
21+
22+
const domain = await db('router_domains').first('id').where({ alias: domainAlias });
23+
if (!domain) {
24+
throw getJoiErr('domainAlias', `Router domain with alias "${domainAlias}" does not exist`);
25+
}
26+
27+
return { ...rest, enforceDomain: domain.id } as ResolvedAppDomainAlias<T>;
28+
}

registry/server/common/services/entries/ApplicationEntry.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Knex } from 'knex';
22
import { User } from '../../../../typings/User';
33
import { App, appSchema, partialAppSchema } from '../../../apps/interfaces';
4+
import { resolveDomainAlias } from '../../../apps/services/resolveDomainAlias';
45
import { VersionedKnex } from '../../../db';
56
import { Tables } from '../../../db/structure';
67
import { EntityTypes } from '../../../versioning/interfaces';
@@ -39,10 +40,11 @@ export class ApplicationEntry implements Entry {
3940
throw new ValidationFqrnError('Patch does not contain any items to update');
4041
}
4142

42-
const appManifest = await this.getManifest(partialAppDTO.assetsDiscoveryUrl);
43+
const resolvedAppDTO = await resolveDomainAlias(partialAppDTO);
44+
const appManifest = await this.getManifest(resolvedAppDTO.assetsDiscoveryUrl);
4345

4446
const appEntity = {
45-
...partialAppDTO,
47+
...resolvedAppDTO,
4648
...appManifest,
4749
};
4850

@@ -66,10 +68,11 @@ export class ApplicationEntry implements Entry {
6668
}
6769

6870
public async create(appDTO: App, { user }: CommonOptions) {
69-
const appManifest = await this.getManifest(appDTO.assetsDiscoveryUrl);
71+
const resolvedAppDTO = await resolveDomainAlias(appDTO);
72+
const appManifest = await this.getManifest(resolvedAppDTO.assetsDiscoveryUrl);
7073

7174
const appEntity = {
72-
...appDTO,
75+
...resolvedAppDTO,
7376
...appManifest,
7477
};
7578

@@ -84,11 +87,12 @@ export class ApplicationEntry implements Entry {
8487

8588
public async upsert(params: unknown, { user, trxProvider, fetchManifest = true }: UpsertOptions): Promise<App> {
8689
const appDto = await appSchema.validateAsync(params, { noDefaults: false, externals: true });
90+
const resolvedAppDTO = await resolveDomainAlias(appDto);
8791

88-
const appManifest = fetchManifest ? await this.getManifest(appDto.assetsDiscoveryUrl) : {};
92+
const appManifest = fetchManifest ? await this.getManifest(resolvedAppDTO.assetsDiscoveryUrl) : {};
8993

9094
const appEntity = {
91-
...appDto,
95+
...resolvedAppDTO,
9296
...appManifest,
9397
};
9498

registry/tests/apps.spec.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,64 @@ describe(`Tests ${example.url}`, () => {
217217
}
218218
});
219219

220+
it('should create record with domainAlias', async () => {
221+
let domainId;
222+
const templateName = 'templateName';
223+
224+
try {
225+
await req
226+
.post('/api/v1/template/')
227+
.send({ name: templateName, content: '<html><head></head><body>foo bar</body></html>' })
228+
.expect(200);
229+
230+
const responseRouterDomains = await req
231+
.post('/api/v1/router_domains/')
232+
.send({ domainName: 'foo.com', template500: templateName, alias: 'app-domain' })
233+
.expect(200);
234+
domainId = responseRouterDomains.body.id;
235+
236+
const response = await req
237+
.post(example.url)
238+
.send({ ...example.correct, domainAlias: 'app-domain' })
239+
.expect(200);
240+
241+
expect(response.body).to.deep.equal({
242+
...example.correct,
243+
enforceDomain: domainId,
244+
});
245+
} finally {
246+
await req.delete(example.url + example.encodedName);
247+
domainId && (await req.delete('/api/v1/router_domains/' + domainId));
248+
await req.delete('/api/v1/template/' + templateName);
249+
}
250+
});
251+
252+
it('should not create record with non-existing domainAlias', async () => {
253+
try {
254+
await req
255+
.post(example.url)
256+
.send({ ...example.correct, domainAlias: 'non-existing' })
257+
.expect(422);
258+
259+
await req.get(example.url + example.encodedName).expect(404);
260+
} finally {
261+
await req.delete(example.url + example.encodedName);
262+
}
263+
});
264+
265+
it('should not create record with both enforceDomain and domainAlias', async () => {
266+
try {
267+
await req
268+
.post(example.url)
269+
.send({ ...example.correct, enforceDomain: 1, domainAlias: 'some-alias' })
270+
.expect(422);
271+
272+
await req.get(example.url + example.encodedName).expect(404);
273+
} finally {
274+
await req.delete(example.url + example.encodedName);
275+
}
276+
});
277+
220278
it('should not create record with non-existed enforceDomain', async () => {
221279
try {
222280
await req
@@ -859,6 +917,113 @@ describe(`Tests ${example.url}`, () => {
859917
}
860918
});
861919

920+
it('should successfully update record with domainAlias', async () => {
921+
let domainId;
922+
const templateName = 'templateName';
923+
924+
try {
925+
await req
926+
.post('/api/v1/template/')
927+
.send({ name: templateName, content: '<html><head></head><body>foo bar</body></html>' })
928+
.expect(200);
929+
930+
const responseRouterDomains = await req
931+
.post('/api/v1/router_domains/')
932+
.send({ domainName: 'foo.com', template500: templateName, alias: 'app-domain-update' })
933+
.expect(200);
934+
domainId = responseRouterDomains.body.id;
935+
936+
await req.post(example.url).send(example.correct).expect(200);
937+
938+
const response = await req
939+
.put(example.url + example.encodedName)
940+
.send({
941+
..._.omit(example.updated, 'name'),
942+
domainAlias: 'app-domain-update',
943+
})
944+
.expect(200);
945+
946+
expect(response.body).to.deep.equal({
947+
...example.updated,
948+
enforceDomain: domainId,
949+
});
950+
} finally {
951+
await req.delete(example.url + example.encodedName);
952+
domainId && (await req.delete('/api/v1/router_domains/' + domainId));
953+
await req.delete('/api/v1/template/' + templateName);
954+
}
955+
});
956+
957+
it('should preserve enforceDomain on unrelated partial updates', async () => {
958+
let domainId;
959+
const templateName = 'templateName';
960+
961+
try {
962+
await req
963+
.post('/api/v1/template/')
964+
.send({ name: templateName, content: '<html><head></head><body>foo bar</body></html>' })
965+
.expect(200);
966+
967+
const responseRouterDomains = await req
968+
.post('/api/v1/router_domains/')
969+
.send({ domainName: 'foo.com', template500: templateName, alias: 'partial-update-domain' })
970+
.expect(200);
971+
domainId = responseRouterDomains.body.id;
972+
973+
await req
974+
.post(example.url)
975+
.send({ ...example.correct, domainAlias: 'partial-update-domain' })
976+
.expect(200);
977+
978+
const response = await req
979+
.put(example.url + example.encodedName)
980+
.send({ adminNotes: 'Updated notes only' })
981+
.expect(200);
982+
983+
expect(response.body).to.deep.include({
984+
adminNotes: 'Updated notes only',
985+
enforceDomain: domainId,
986+
});
987+
} finally {
988+
await req.delete(example.url + example.encodedName);
989+
domainId && (await req.delete('/api/v1/router_domains/' + domainId));
990+
await req.delete('/api/v1/template/' + templateName);
991+
}
992+
});
993+
994+
it('should not update record with non-existing domainAlias', async () => {
995+
try {
996+
await req.post(example.url).send(example.correct).expect(200);
997+
998+
await req
999+
.put(example.url + example.encodedName)
1000+
.send({
1001+
..._.omit(example.updated, 'name'),
1002+
domainAlias: 'non-existing',
1003+
})
1004+
.expect(422);
1005+
} finally {
1006+
await req.delete(example.url + example.encodedName);
1007+
}
1008+
});
1009+
1010+
it('should not update record with both enforceDomain and domainAlias', async () => {
1011+
try {
1012+
await req.post(example.url).send(example.correct).expect(200);
1013+
1014+
await req
1015+
.put(example.url + example.encodedName)
1016+
.send({
1017+
..._.omit(example.updated, 'name'),
1018+
enforceDomain: 1,
1019+
domainAlias: 'some-alias',
1020+
})
1021+
.expect(422);
1022+
} finally {
1023+
await req.delete(example.url + example.encodedName);
1024+
}
1025+
});
1026+
8621027
it('should be possible to remove\reset fields valued during update', async () => {
8631028
let domainId;
8641029
const templateName = 'templateName';

0 commit comments

Comments
 (0)