0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-24 22:05:56 -05:00

feat(core): add domain routes (#3892)

This commit is contained in:
wangsijie 2023-05-29 11:27:23 +08:00 committed by GitHub
parent 43dd5e6d11
commit 0edd549365
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 448 additions and 0 deletions

View file

@ -0,0 +1,23 @@
import { type Domain, type DomainResponse } from '@logto/schemas';
export const mockNanoIdForDomain = 'random_string';
export const mockCreatedAtForDomain = 1_650_969_000_000;
export const mockTenantIdForHook = 'fake_tenant';
export const mockDomainResponse: DomainResponse = {
id: mockNanoIdForDomain,
domain: 'logto.example.com',
status: 'pending',
errorMessage: null,
dnsRecords: [],
};
export const mockDomain: Domain = {
...mockDomainResponse,
tenantId: mockTenantIdForHook,
cloudflareData: null,
updatedAt: mockCreatedAtForDomain,
createdAt: mockCreatedAtForDomain,
};

View file

@ -13,6 +13,7 @@ import { ApplicationType } from '@logto/schemas';
export * from './connector.js';
export * from './sign-in-experience.js';
export * from './user.js';
export * from './domain.js';
export const mockApplication: Application = {
tenantId: 'fake_tenant',

View file

@ -0,0 +1,56 @@
import type { CreateDomain, Domain } from '@logto/schemas';
import { Domains } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared';
import { convertToIdentifiers, manyRows } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
import { DeletionError } from '#src/errors/SlonikError/index.js';
const { table, fields } = convertToIdentifiers(Domains);
export const createDomainsQueries = (pool: CommonQueryMethods) => {
const findAllDomains = async () =>
manyRows(
pool.query<Domain>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
`)
);
const findDomainById = buildFindEntityByIdWithPool(pool)(Domains);
const insertDomain = buildInsertIntoWithPool(pool)(Domains, {
returning: true,
});
const updateDomain = buildUpdateWhereWithPool(pool)(Domains, true);
const updateDomainById = async (
id: string,
set: Partial<OmitAutoSetFields<CreateDomain>>,
jsonbMode: 'replace' | 'merge' = 'replace'
) => updateDomain({ set, where: { id }, jsonbMode });
const deleteDomainById = async (id: string) => {
const { rowCount } = await pool.query(sql`
delete from ${table}
where ${fields.id}=${id}
`);
if (rowCount < 1) {
throw new DeletionError(Domains.table, id);
}
};
return {
findAllDomains,
findDomainById,
insertDomain,
updateDomainById,
deleteDomainById,
};
};

View file

@ -0,0 +1,69 @@
import { type Domain, type CreateDomain } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { mockDomain, mockDomainResponse } from '#src/__mocks__/domain.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const domains = {
findAllDomains: jest.fn(async (): Promise<Domain[]> => [mockDomain]),
insertDomain: async (data: CreateDomain): Promise<Domain> => ({
...mockDomain,
...data,
}),
findDomainById: async (id: string): Promise<Domain> => {
const domain = [mockDomain].find((domain) => domain.id === id);
if (!domain) {
throw new Error('Not found');
}
return domain;
},
deleteDomainById: jest.fn(),
};
const tenantContext = new MockTenant(undefined, { domains });
const domainRoutes = await pickDefault(import('./domain.js'));
describe('domain routes', () => {
const domainRequest = createRequester({ authedRoutes: domainRoutes, tenantContext });
afterEach(() => {
jest.clearAllMocks();
});
it('GET /domains', async () => {
const response = await domainRequest.get('/domains');
expect(response.status).toEqual(200);
expect(response.body).toEqual([mockDomainResponse]);
});
it('GET /domains/:id', async () => {
const response = await domainRequest.get(`/domains/${mockDomain.id}`);
expect(response.status).toEqual(200);
expect(response.body).toMatchObject(mockDomainResponse);
});
it('POST /domains', async () => {
domains.findAllDomains.mockResolvedValueOnce([]);
const response = await domainRequest.post('/domains').send({ domain: 'test.com' });
expect(response.status).toEqual(201);
expect(response.body.id).toBeTruthy();
expect(response.body.domain).toEqual('test.com');
});
it('POST /domains should fail when there is already a domain', async () => {
await expect(
domainRequest.post('/domains').send({ domain: mockDomain.domain })
).resolves.toHaveProperty('status', 422);
});
it('DELETE /domains/:id', async () => {
await expect(domainRequest.delete(`/domains/${mockDomain.id}`)).resolves.toHaveProperty(
'status',
204
);
});
});

View file

@ -0,0 +1,90 @@
import { Domains, domainResponseGuard, domainSelectFields } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { pick } from '@silverhand/essentials';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function domainRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const {
domains: { findAllDomains, findDomainById, insertDomain, deleteDomainById },
} = queries;
router.get(
'/domains',
koaGuard({ response: domainResponseGuard.array(), status: 200 }),
async (ctx, next) => {
const domains = await findAllDomains();
ctx.body = domains.map((domain) => pick(domain, ...domainSelectFields));
return next();
}
);
router.get(
'/domains/:id',
koaGuard({
params: z.object({ id: z.string() }),
response: domainResponseGuard,
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const domain = await findDomainById(id);
ctx.body = pick(domain, ...domainSelectFields);
return next();
}
);
router.post(
'/domains',
koaGuard({
body: Domains.createGuard.pick({ domain: true }),
response: domainResponseGuard,
status: [201, 422],
}),
async (ctx, next) => {
const existingDomains = await findAllDomains();
assertThat(
existingDomains.length === 0,
new RequestError({
code: 'domain.limit_to_one_domain',
status: 422,
})
);
const domain = await insertDomain({
...ctx.guard.body,
id: generateStandardId(),
});
ctx.status = 201;
ctx.body = pick(domain, ...domainSelectFields);
return next();
}
);
router.delete(
'/domains/:id',
koaGuard({ params: z.object({ id: z.string() }), status: [204, 404] }),
async (ctx, next) => {
const { id } = ctx.guard.params;
await deleteDomainById(id);
ctx.status = 204;
return next();
}
);
}

View file

@ -18,6 +18,7 @@ import authnRoutes from './authn.js';
import connectorRoutes from './connector/index.js';
import customPhraseRoutes from './custom-phrase.js';
import dashboardRoutes from './dashboard.js';
import domainRoutes from './domain.js';
import hookRoutes from './hook.js';
import interactionRoutes from './interaction/index.js';
import logRoutes from './log.js';
@ -56,6 +57,7 @@ const createRouters = (tenant: TenantContext) => {
hookRoutes(managementRouter, tenant);
verificationCodeRoutes(managementRouter, tenant);
userAssetsRoutes(managementRouter, tenant);
domainRoutes(managementRouter, tenant);
const anonymousRouter: AnonymousRouter = new Router();
wellKnownRoutes(anonymousRouter, tenant);

View file

@ -5,6 +5,7 @@ import { createApplicationQueries } from '#src/queries/application.js';
import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js';
import { createConnectorQueries } from '#src/queries/connector.js';
import { createCustomPhraseQueries } from '#src/queries/custom-phrase.js';
import { createDomainsQueries } from '#src/queries/domains.js';
import { createHooksQueries } from '#src/queries/hooks.js';
import { createLogQueries } from '#src/queries/log.js';
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
@ -37,6 +38,7 @@ export default class Queries {
applicationsRoles = createApplicationsRolesQueries(this.pool);
verificationStatuses = createVerificationStatusQueries(this.pool);
hooks = createHooksQueries(this.pool);
domains = createDomainsQueries(this.pool);
constructor(
public readonly pool: CommonQueryMethods,

View file

@ -0,0 +1,22 @@
import type { DomainResponse } from '@logto/schemas';
import { generateDomain } from '#src/utils.js';
import { authedAdminApi } from './api.js';
export const createDomain = async (domain?: string) =>
authedAdminApi
.post('domains', {
json: {
domain: domain ?? generateDomain(),
},
})
.json<DomainResponse>();
export const getDomains = async () => authedAdminApi.get('domains').json<DomainResponse[]>();
export const getDomain = async (domainId: string) =>
authedAdminApi.get(`domains/${domainId}`).json<DomainResponse>();
export const deleteDomain = async (domainId: string) =>
authedAdminApi.delete(`domains/${domainId}`);

View file

@ -7,5 +7,6 @@ export * from './logs.js';
export * from './dashboard.js';
export * from './interaction.js';
export * from './logto-config.js';
export * from './domain.js';
export { default as api, authedAdminApi } from './api.js';

View file

@ -0,0 +1,54 @@
import { HTTPError } from 'got';
import { createDomain, deleteDomain, getDomain, getDomains } from '#src/api/domain.js';
import { generateDomain } from '#src/utils.js';
describe('domains', () => {
afterEach(async () => {
const domains = await getDomains();
await Promise.all(domains.map(async (domain) => deleteDomain(domain.id)));
});
it('should get domains list successfully', async () => {
await createDomain();
const domains = await getDomains();
expect(domains.length > 0).toBeTruthy();
});
it('should create domain successfully', async () => {
const domainName = generateDomain();
const domain = await createDomain(domainName);
expect(domain.domain).toBe(domainName);
});
it('should fail when already has a domain', async () => {
await createDomain();
const response = await createDomain().catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(422);
});
it('should get domain detail successfully', async () => {
const createdDomain = await createDomain();
const domain = await getDomain(createdDomain.id);
expect(domain.domain).toBe(createdDomain.domain);
});
it('should return 404 if domain does not exist', async () => {
const response = await getDomain('non_existent_domain').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should delete domain successfully', async () => {
const domain = await createDomain();
await deleteDomain(domain.id);
const response = await getDomain(domain.id).catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
});

View file

@ -13,6 +13,7 @@ export const generateResourceIndicator = () => `https://${crypto.randomUUID()}.l
export const generateEmail = () => `${crypto.randomUUID().toLowerCase()}@logto.io`;
export const generateScopeName = () => `sc:${crypto.randomUUID()}`;
export const generateRoleName = () => `role_${crypto.randomUUID()}`;
export const generateDomain = () => `${crypto.randomUUID().toLowerCase().slice(0, 5)}.example.com`;
export const generatePhone = (isE164?: boolean) => {
const plus = isE164 ? '+' : '';

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'Sie können nur eine benutzerdefinierte Domain haben.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'You can only have one custom domain.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'Solo puedes tener un dominio personalizado.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domaine = {
limit_to_one_domain: "Vous ne pouvez avoir qu'un seul domaine personnalisé.",
};
export default domaine;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'Puoi avere solo un dominio personalizzato.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'カスタムドメインは1つしか持てません。',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: '하나의 맞춤 도메인만 사용할 수 있습니다.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'Możesz mieć tylko jedną niestandardową domenę.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'Você só pode ter um domínio personalizado.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'Você só pode ter um domínio personalizado.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'Вы можете использовать только один пользовательский домен.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: 'Sadece bir özel alan adınız olabilir.',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: '仅限一个自定义域名。',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: '您只能有一个自定义域名。',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,5 @@
const domain = {
limit_to_one_domain: '您只能擁有一個自訂網域。',
};
export default domain;

View file

@ -1,5 +1,6 @@
import auth from './auth.js';
import connector from './connector.js';
import domain from './domain.js';
import entity from './entity.js';
import guard from './guard.js';
import hook from './hook.js';
@ -38,6 +39,7 @@ const errors = {
storage,
resource,
hook,
domain,
};
export default errors;

View file

@ -0,0 +1,21 @@
import { type z } from 'zod';
import { Domains } from '../db-entries/index.js';
export const domainSelectFields = Object.freeze([
'id',
'domain',
'status',
'errorMessage',
'dnsRecords',
] as const);
export const domainResponseGuard = Domains.guard.pick({
id: true,
domain: true,
status: true,
errorMessage: true,
dnsRecords: true,
});
export type DomainResponse = z.infer<typeof domainResponseGuard>;

View file

@ -18,3 +18,4 @@ export * from './service-log.js';
export * from './theme.js';
export * from './cookie.js';
export * from './dashboard.js';
export * from './domain.js';