mirror of
https://github.com/LukeHagar/redocly-cli.git
synced 2025-12-06 04:21:09 +00:00
feat: report endpoint sunset (#1677)
This commit is contained in:
committed by
Luke Hagar
parent
b574cdb36b
commit
07c053936e
5
.changeset/tiny-boats-check.md
Normal file
5
.changeset/tiny-boats-check.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@redocly/cli": patch
|
||||
---
|
||||
|
||||
Added a warning message to the `push` and `push-status` commands to notify users about upcoming or ongoing resource deprecation.
|
||||
@@ -1,7 +1,8 @@
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import * as FormData from 'form-data';
|
||||
import { red, yellow } from 'colorette';
|
||||
|
||||
import { ReuniteApiClient, PushPayload, ReuniteApiError } from '../api-client';
|
||||
import { ReuniteApi, PushPayload, ReuniteApiError } from '../api-client';
|
||||
|
||||
jest.mock('node-fetch', () => ({
|
||||
default: jest.fn(),
|
||||
@@ -21,10 +22,10 @@ describe('ApiClient', () => {
|
||||
const expectedUserAgent = `redocly-cli/${version} ${command}`;
|
||||
|
||||
describe('getDefaultBranch()', () => {
|
||||
let apiClient: ReuniteApiClient;
|
||||
let apiClient: ReuniteApi;
|
||||
|
||||
beforeEach(() => {
|
||||
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
|
||||
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
|
||||
});
|
||||
|
||||
it('should get default project branch', async () => {
|
||||
@@ -90,22 +91,23 @@ describe('ApiClient', () => {
|
||||
mountBranchName: 'remote-mount-branch-name',
|
||||
mountPath: 'remote-mount-path',
|
||||
};
|
||||
let apiClient: ReuniteApiClient;
|
||||
|
||||
const responseMock = {
|
||||
id: 'remote-id',
|
||||
type: 'CICD',
|
||||
mountPath: 'remote-mount-path',
|
||||
mountBranchName: 'remote-mount-branch-name',
|
||||
organizationId: testOrg,
|
||||
projectId: testProject,
|
||||
};
|
||||
|
||||
let apiClient: ReuniteApi;
|
||||
|
||||
beforeEach(() => {
|
||||
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
|
||||
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
|
||||
});
|
||||
|
||||
it('should upsert remote', async () => {
|
||||
const responseMock = {
|
||||
id: 'remote-id',
|
||||
type: 'CICD',
|
||||
mountPath: 'remote-mount-path',
|
||||
mountBranchName: 'remote-mount-branch-name',
|
||||
organizationId: testOrg,
|
||||
projectId: testProject,
|
||||
};
|
||||
|
||||
mockFetchResponse({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue(responseMock),
|
||||
@@ -204,10 +206,10 @@ describe('ApiClient', () => {
|
||||
outdated: false,
|
||||
};
|
||||
|
||||
let apiClient: ReuniteApiClient;
|
||||
let apiClient: ReuniteApi;
|
||||
|
||||
beforeEach(() => {
|
||||
apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
|
||||
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
|
||||
});
|
||||
|
||||
it('should push to remote', async () => {
|
||||
@@ -284,4 +286,165 @@ describe('ApiClient', () => {
|
||||
).rejects.toThrow(new ReuniteApiError('Failed to push. Not found.', 404));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sunset header', () => {
|
||||
const upsertRemoteMock = {
|
||||
requestFn: () =>
|
||||
apiClient.remotes.upsert(testOrg, testProject, {
|
||||
mountBranchName: 'remote-mount-branch-name',
|
||||
mountPath: 'remote-mount-path',
|
||||
}),
|
||||
responseBody: {
|
||||
id: 'remote-id',
|
||||
type: 'CICD',
|
||||
mountPath: 'remote-mount-path',
|
||||
mountBranchName: 'remote-mount-branch-name',
|
||||
organizationId: testOrg,
|
||||
projectId: testProject,
|
||||
},
|
||||
};
|
||||
|
||||
const getDefaultBranchMock = {
|
||||
requestFn: () => apiClient.remotes.getDefaultBranch(testOrg, testProject),
|
||||
responseBody: {
|
||||
branchName: 'test-branch',
|
||||
},
|
||||
};
|
||||
|
||||
const pushMock = {
|
||||
requestFn: () =>
|
||||
apiClient.remotes.push(
|
||||
testOrg,
|
||||
testProject,
|
||||
{
|
||||
remoteId: 'test-remote-id',
|
||||
commit: {
|
||||
message: 'test-message',
|
||||
author: {
|
||||
name: 'test-name',
|
||||
email: 'test-email',
|
||||
},
|
||||
branchName: 'test-branch-name',
|
||||
},
|
||||
},
|
||||
[{ path: 'some-file.yaml', stream: Buffer.from('text content') }]
|
||||
),
|
||||
responseBody: {
|
||||
branchName: 'rem/cicd/rem_01he7sr6ys2agb7w0g9t7978fn-main',
|
||||
hasChanges: true,
|
||||
files: [
|
||||
{
|
||||
type: 'file',
|
||||
name: 'some-file.yaml',
|
||||
path: 'docs/remotes/some-file.yaml',
|
||||
lastModified: 1698925132394.2993,
|
||||
mimeType: 'text/yaml',
|
||||
},
|
||||
],
|
||||
commitSha: 'bb23a2f8e012ac0b7b9961b57fb40d8686b21b43',
|
||||
outdated: false,
|
||||
},
|
||||
};
|
||||
|
||||
const endpointMocks = [upsertRemoteMock, getDefaultBranchMock, pushMock];
|
||||
|
||||
let apiClient: ReuniteApi;
|
||||
|
||||
beforeEach(() => {
|
||||
apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
|
||||
});
|
||||
|
||||
it.each(endpointMocks)(
|
||||
'should report endpoint sunset in the past',
|
||||
async ({ responseBody, requestFn }) => {
|
||||
jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
|
||||
const sunsetDate = new Date('2024-09-06T12:30:32.456Z');
|
||||
|
||||
mockFetchResponse({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue(responseBody),
|
||||
headers: new Headers({
|
||||
Sunset: sunsetDate.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
await requestFn();
|
||||
apiClient.reportSunsetWarnings();
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
red(
|
||||
`The "push" command is not compatible with your version of Redocly CLI. Update to the latest version by running "npm install @redocly/cli@latest".\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(endpointMocks)(
|
||||
'should report endpoint sunset in the future',
|
||||
async ({ responseBody, requestFn }) => {
|
||||
jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
|
||||
const sunsetDate = new Date(Date.now() + 1000 * 60 * 60 * 24);
|
||||
|
||||
mockFetchResponse({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue(responseBody),
|
||||
headers: new Headers({
|
||||
Sunset: sunsetDate.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
await requestFn();
|
||||
apiClient.reportSunsetWarnings();
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
yellow(
|
||||
`The "push" command will be incompatible with your version of Redocly CLI after ${sunsetDate.toLocaleString()}. Update to the latest version by running "npm install @redocly/cli@latest".\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('should report only expired resource', async () => {
|
||||
jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
|
||||
|
||||
mockFetchResponse({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue(upsertRemoteMock.responseBody),
|
||||
headers: new Headers({
|
||||
Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
await upsertRemoteMock.requestFn();
|
||||
|
||||
mockFetchResponse({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue(getDefaultBranchMock.responseBody),
|
||||
headers: new Headers({
|
||||
Sunset: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
await getDefaultBranchMock.requestFn();
|
||||
|
||||
mockFetchResponse({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue(pushMock.responseBody),
|
||||
headers: new Headers({
|
||||
Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
await pushMock.requestFn();
|
||||
|
||||
apiClient.reportSunsetWarnings();
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledTimes(1);
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
red(
|
||||
`The "push" command is not compatible with your version of Redocly CLI. Update to the latest version by running "npm install @redocly/cli@latest".\n\n`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { yellow, red } from 'colorette';
|
||||
import * as FormData from 'form-data';
|
||||
import fetchWithTimeout, {
|
||||
type FetchWithTimeoutOptions,
|
||||
@@ -13,15 +14,84 @@ import type {
|
||||
UpsertRemoteResponse,
|
||||
} from './types';
|
||||
|
||||
interface BaseApiClient {
|
||||
request(url: string, options: FetchWithTimeoutOptions): Promise<Response>;
|
||||
}
|
||||
type CommandOption = 'push' | 'push-status';
|
||||
export type SunsetWarning = { sunsetDate: Date; isSunsetExpired: boolean };
|
||||
export type SunsetWarningsBuffer = SunsetWarning[];
|
||||
|
||||
export class ReuniteApiError extends Error {
|
||||
constructor(message: string, public status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
class ReuniteBaseApiClient {
|
||||
class ReuniteApiClient implements BaseApiClient {
|
||||
public sunsetWarnings: SunsetWarningsBuffer = [];
|
||||
|
||||
constructor(protected version: string, protected command: string) {}
|
||||
|
||||
public async request(url: string, options: FetchWithTimeoutOptions) {
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'user-agent': `redocly-cli/${this.version.trim()} ${this.command}`,
|
||||
};
|
||||
|
||||
const response = await fetchWithTimeout(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
this.collectSunsetWarning(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private collectSunsetWarning(response: Response) {
|
||||
const sunsetTime = this.getSunsetDate(response);
|
||||
|
||||
if (!sunsetTime) return;
|
||||
|
||||
const sunsetDate = new Date(sunsetTime);
|
||||
|
||||
if (sunsetTime > Date.now()) {
|
||||
this.sunsetWarnings.push({
|
||||
sunsetDate,
|
||||
isSunsetExpired: false,
|
||||
});
|
||||
} else {
|
||||
this.sunsetWarnings.push({
|
||||
sunsetDate,
|
||||
isSunsetExpired: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getSunsetDate(response: Response): number | undefined {
|
||||
const { headers } = response;
|
||||
|
||||
if (!headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sunsetDate = headers.get('sunset') || headers.get('Sunset');
|
||||
|
||||
if (!sunsetDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Date.parse(sunsetDate);
|
||||
}
|
||||
}
|
||||
|
||||
class RemotesApi {
|
||||
constructor(
|
||||
private client: BaseApiClient,
|
||||
private readonly domain: string,
|
||||
private readonly apiKey: string
|
||||
) {}
|
||||
|
||||
protected async getParsedResponse<T>(response: Response): Promise<T> {
|
||||
const responseBody = await response.json();
|
||||
|
||||
@@ -35,32 +105,9 @@ class ReuniteBaseApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
protected request(url: string, options: FetchWithTimeoutOptions) {
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'user-agent': `redocly-cli/${this.version.trim()} ${this.command}`,
|
||||
};
|
||||
|
||||
return fetchWithTimeout(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class RemotesApiClient extends ReuniteBaseApiClient {
|
||||
constructor(
|
||||
private readonly domain: string,
|
||||
private readonly apiKey: string,
|
||||
version: string,
|
||||
command: string
|
||||
) {
|
||||
super(version, command);
|
||||
}
|
||||
|
||||
async getDefaultBranch(organizationId: string, projectId: string) {
|
||||
try {
|
||||
const response = await this.request(
|
||||
const response = await this.client.request(
|
||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
|
||||
{
|
||||
timeout: DEFAULT_FETCH_TIMEOUT,
|
||||
@@ -95,7 +142,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
|
||||
}
|
||||
): Promise<UpsertRemoteResponse> {
|
||||
try {
|
||||
const response = await this.request(
|
||||
const response = await this.client.request(
|
||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
|
||||
{
|
||||
timeout: DEFAULT_FETCH_TIMEOUT,
|
||||
@@ -150,7 +197,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
|
||||
|
||||
payload.isMainBranch && formData.append('isMainBranch', 'true');
|
||||
try {
|
||||
const response = await this.request(
|
||||
const response = await this.client.request(
|
||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -183,7 +230,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
|
||||
mountPath: string;
|
||||
}) {
|
||||
try {
|
||||
const response = await this.request(
|
||||
const response = await this.client.request(
|
||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
|
||||
{
|
||||
timeout: DEFAULT_FETCH_TIMEOUT,
|
||||
@@ -217,7 +264,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
|
||||
pushId: string;
|
||||
}) {
|
||||
try {
|
||||
const response = await this.request(
|
||||
const response = await this.client.request(
|
||||
`${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
|
||||
{
|
||||
timeout: DEFAULT_FETCH_TIMEOUT,
|
||||
@@ -242,8 +289,12 @@ class RemotesApiClient extends ReuniteBaseApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class ReuniteApiClient {
|
||||
remotes: RemotesApiClient;
|
||||
export class ReuniteApi {
|
||||
private apiClient: ReuniteApiClient;
|
||||
private version: string;
|
||||
private command: CommandOption;
|
||||
|
||||
public remotes: RemotesApi;
|
||||
|
||||
constructor({
|
||||
domain,
|
||||
@@ -254,9 +305,49 @@ export class ReuniteApiClient {
|
||||
domain: string;
|
||||
apiKey: string;
|
||||
version: string;
|
||||
command: 'push' | 'push-status';
|
||||
command: CommandOption;
|
||||
}) {
|
||||
this.remotes = new RemotesApiClient(domain, apiKey, version, command);
|
||||
this.command = command;
|
||||
this.version = version;
|
||||
this.apiClient = new ReuniteApiClient(this.version, this.command);
|
||||
|
||||
this.remotes = new RemotesApi(this.apiClient, domain, apiKey);
|
||||
}
|
||||
|
||||
public reportSunsetWarnings(): void {
|
||||
const sunsetWarnings = this.apiClient.sunsetWarnings;
|
||||
|
||||
if (sunsetWarnings.length) {
|
||||
const [{ isSunsetExpired, sunsetDate }] = sunsetWarnings.sort(
|
||||
(a: SunsetWarning, b: SunsetWarning) => {
|
||||
// First, prioritize by expiration status
|
||||
if (a.isSunsetExpired !== b.isSunsetExpired) {
|
||||
return a.isSunsetExpired ? -1 : 1;
|
||||
}
|
||||
|
||||
// If both are either expired or not, sort by sunset date
|
||||
return a.sunsetDate > b.sunsetDate ? 1 : -1;
|
||||
}
|
||||
);
|
||||
|
||||
const updateVersionMessage = `Update to the latest version by running "npm install @redocly/cli@latest".`;
|
||||
|
||||
if (isSunsetExpired) {
|
||||
process.stdout.write(
|
||||
red(
|
||||
`The "${this.command}" command is not compatible with your version of Redocly CLI. ${updateVersionMessage}\n\n`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
yellow(
|
||||
`The "${
|
||||
this.command
|
||||
}" command will be incompatible with your version of Redocly CLI after ${sunsetDate.toLocaleString()}. ${updateVersionMessage}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ jest.mock('colorette', () => ({
|
||||
|
||||
jest.mock('../../api', () => ({
|
||||
...jest.requireActual('../../api'),
|
||||
ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) {
|
||||
ReuniteApi: jest.fn().mockImplementation(function (this: any, ...args) {
|
||||
this.remotes = remotes;
|
||||
this.reportSunsetWarnings = jest.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { handlePush } from '../push';
|
||||
import { ReuniteApiClient, ReuniteApiError } from '../../api';
|
||||
import { ReuniteApi, ReuniteApiError } from '../../api';
|
||||
|
||||
const remotes = {
|
||||
push: jest.fn(),
|
||||
@@ -15,8 +15,9 @@ jest.mock('@redocly/openapi-core', () => ({
|
||||
|
||||
jest.mock('../../api', () => ({
|
||||
...jest.requireActual('../../api'),
|
||||
ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) {
|
||||
ReuniteApi: jest.fn().mockImplementation(function (this: any, ...args) {
|
||||
this.remotes = remotes;
|
||||
this.reportSunsetWarnings = jest.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -332,7 +333,7 @@ describe('handlePush()', () => {
|
||||
version: 'cli-version',
|
||||
});
|
||||
|
||||
expect(ReuniteApiClient).toBeCalledWith({
|
||||
expect(ReuniteApi).toBeCalledWith({
|
||||
domain: 'test-domain-from-env',
|
||||
apiKey: 'test-api-key',
|
||||
version: 'cli-version',
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as colors from 'colorette';
|
||||
import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
|
||||
import { Spinner } from '../../utils/spinner';
|
||||
import { DeploymentError } from '../utils';
|
||||
import { ReuniteApiClient, getApiKeys, getDomain } from '../api';
|
||||
import { ReuniteApi, getApiKeys, getDomain } from '../api';
|
||||
import { capitalize } from '../../utils/js-utils';
|
||||
import { handleReuniteError, retryUntilConditionMet } from './utils';
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function handlePushStatus({
|
||||
|
||||
try {
|
||||
const apiKey = getApiKeys(domain);
|
||||
const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push-status' });
|
||||
const client = new ReuniteApi({ domain, apiKey, version, command: 'push-status' });
|
||||
|
||||
let pushResponse: PushResponse;
|
||||
|
||||
@@ -169,6 +169,8 @@ export async function handlePushStatus({
|
||||
}
|
||||
printPushStatusInfo({ orgId, projectId, pushId, startedAt });
|
||||
|
||||
client.reportSunsetWarnings();
|
||||
|
||||
const summary: PushStatusSummary = {
|
||||
preview: pushResponse.status.preview,
|
||||
production: pushResponse.isMainBranch ? pushResponse.status.production : null,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { pluralize } from '@redocly/openapi-core/lib/utils';
|
||||
import { green, yellow } from 'colorette';
|
||||
import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
|
||||
import { handlePushStatus } from './push-status';
|
||||
import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
|
||||
import { ReuniteApi, getDomain, getApiKeys } from '../api';
|
||||
import { handleReuniteError } from './utils';
|
||||
|
||||
import type { OutputFormat } from '@redocly/openapi-core';
|
||||
@@ -81,12 +81,13 @@ export async function handlePush({
|
||||
const author = parseCommitAuthor(argv.author);
|
||||
const apiKey = getApiKeys(domain);
|
||||
const filesToUpload = collectFilesToPush(argv.files || argv.apis);
|
||||
const commandName = 'push' as const;
|
||||
|
||||
if (!filesToUpload.length) {
|
||||
return printExecutionTime('push', startedAt, `No files to upload`);
|
||||
return printExecutionTime(commandName, startedAt, `No files to upload`);
|
||||
}
|
||||
|
||||
const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push' });
|
||||
const client = new ReuniteApi({ domain, apiKey, version, command: commandName });
|
||||
const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId);
|
||||
const remote = await client.remotes.upsert(orgId, projectId, {
|
||||
mountBranchName: projectDefaultBranch,
|
||||
@@ -147,7 +148,7 @@ export async function handlePush({
|
||||
}
|
||||
verbose &&
|
||||
printExecutionTime(
|
||||
'push',
|
||||
commandName,
|
||||
startedAt,
|
||||
`${pluralize(
|
||||
'file',
|
||||
@@ -155,6 +156,8 @@ export async function handlePush({
|
||||
)} uploaded to organization ${orgId}, project ${projectId}. Push ID: ${id}.`
|
||||
);
|
||||
|
||||
client.reportSunsetWarnings();
|
||||
|
||||
return {
|
||||
pushId: id,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user