Adapt react-client to use the real backend API (#1545)

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Co-authored-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Erik Michelson 2022-04-15 23:03:15 +02:00 committed by GitHub
parent 3399ed2023
commit 26f90505ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
227 changed files with 4726 additions and 2310 deletions

48
src/api/alias/index.ts Normal file
View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Alias, NewAliasDto, PrimaryAliasDto } from './types'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
/**
* Adds an alias to an existing note.
* @param noteIdOrAlias The note id or an existing alias for a note.
* @param newAlias The new alias.
* @return Information about the newly created alias.
*/
export const addAlias = async (noteIdOrAlias: string, newAlias: string): Promise<Alias> => {
const response = await new PostApiRequestBuilder<Alias, NewAliasDto>('alias')
.withJsonBody({
noteIdOrAlias,
newAlias
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Marks a given alias as the primary one for a note.
* The former primary alias should be marked as non-primary by the backend automatically.
* @param alias The alias to mark as primary for its corresponding note.
* @return The updated information about the alias.
*/
export const markAliasAsPrimary = async (alias: string): Promise<Alias> => {
const response = await new PutApiRequestBuilder<Alias, PrimaryAliasDto>('alias/' + alias)
.withJsonBody({
primaryAlias: true
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Removes a given alias from its corresponding note.
* @param alias The alias to remove from its note.
*/
export const deleteAlias = async (alias: string): Promise<void> => {
await new DeleteApiRequestBuilder('alias/' + alias).sendRequest()
}

19
src/api/alias/types.ts Normal file
View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Alias {
name: string
primaryAlias: boolean
noteId: string
}
export interface NewAliasDto {
noteIdOrAlias: string
newAlias: string
}
export interface PrimaryAliasDto {
primaryAlias: boolean
}

View file

@ -1,34 +1,14 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
export const INTERACTIVE_LOGIN_METHODS = ['local', 'ldap']
export enum AuthError {
INVALID_CREDENTIALS = 'invalidCredentials',
LOGIN_DISABLED = 'loginDisabled',
OPENID_ERROR = 'openIdError',
OTHER = 'other'
}
export enum RegisterError {
USERNAME_EXISTING = 'usernameExisting',
REGISTRATION_DISABLED = 'registrationDisabled',
OTHER = 'other'
}
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
/**
* Requests to logout the current user.
* Requests to log out the current user.
* @throws Error if logout is not possible.
*/
export const doLogout = async (): Promise<void> => {
const response = await fetch(getApiUrl() + 'auth/logout', {
...defaultFetchConfig,
method: 'DELETE'
})
expectResponseCode(response)
await new DeleteApiRequestBuilder('auth/logout').sendRequest()
}

View file

@ -1,25 +1,28 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import type { LoginDto } from './types'
import { AuthError } from './types'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
/**
* Requests to login a user via LDAP credentials.
* @param provider The identifier of the LDAP provider with which to login.
* @param username The username with which to try the login.
* @param password The password of the user.
* @throws {AuthError.INVALID_CREDENTIALS} if the LDAP provider denied the given credentials.
*/
export const doLdapLogin = async (username: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'auth/ldap', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
export const doLdapLogin = async (provider: string, username: string, password: string): Promise<void> => {
await new PostApiRequestBuilder<void, LoginDto>('auth/ldap/' + provider)
.withJsonBody({
username: username,
password: password
})
})
expectResponseCode(response)
.withStatusCodeErrorMapping({
401: AuthError.INVALID_CREDENTIALS
})
.sendRequest()
}

View file

@ -1,38 +1,31 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import { AuthError, RegisterError } from './index'
import type { ChangePasswordDto, LoginDto, RegisterDto } from './types'
import { AuthError, RegisterError } from './types'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
/**
* Requests to do a local login with a provided username and password.
* @param username The username for which the login should be tried.
* @param password The password which should be used to login.
* @param password The password which should be used to log in.
* @throws {AuthError.INVALID_CREDENTIALS} when the username or password is wrong.
* @throws {AuthError.LOGIN_DISABLED} when the local login is disabled on the backend.
*/
export const doLocalLogin = async (username: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'auth/local/login', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
await new PostApiRequestBuilder<void, LoginDto>('auth/local/login')
.withJsonBody({
username,
password
})
})
if (response.status === 400) {
throw new Error(AuthError.LOGIN_DISABLED)
}
if (response.status === 401) {
throw new Error(AuthError.INVALID_CREDENTIALS)
}
expectResponseCode(response, 201)
.withStatusCodeErrorMapping({
400: AuthError.LOGIN_DISABLED,
401: AuthError.INVALID_CREDENTIALS
})
.sendRequest()
}
/**
@ -40,29 +33,21 @@ export const doLocalLogin = async (username: string, password: string): Promise<
* @param username The username of the new user.
* @param displayName The display name of the new user.
* @param password The password of the new user.
* @throws {RegisterError.USERNAME_EXISTING} when there is already an existing user with the same user name.
* @throws {RegisterError.USERNAME_EXISTING} when there is already an existing user with the same username.
* @throws {RegisterError.REGISTRATION_DISABLED} when the registration of local users has been disabled on the backend.
*/
export const doLocalRegister = async (username: string, displayName: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'auth/local', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
await new PostApiRequestBuilder<void, RegisterDto>('auth/local')
.withJsonBody({
username,
displayName,
password
})
})
if (response.status === 409) {
throw new Error(RegisterError.USERNAME_EXISTING)
}
if (response.status === 400) {
throw new Error(RegisterError.REGISTRATION_DISABLED)
}
expectResponseCode(response)
.withStatusCodeErrorMapping({
400: RegisterError.REGISTRATION_DISABLED,
409: RegisterError.USERNAME_EXISTING
})
.sendRequest()
}
/**
@ -73,22 +58,14 @@ export const doLocalRegister = async (username: string, displayName: string, pas
* @throws {AuthError.LOGIN_DISABLED} when local login is disabled on the backend.
*/
export const doLocalPasswordChange = async (currentPassword: string, newPassword: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'auth/local', {
...defaultFetchConfig,
method: 'PUT',
body: JSON.stringify({
await new PutApiRequestBuilder<void, ChangePasswordDto>('auth/local')
.withJsonBody({
currentPassword,
newPassword
})
})
if (response.status === 401) {
throw new Error(AuthError.INVALID_CREDENTIALS)
}
if (response.status === 400) {
throw new Error(AuthError.LOGIN_DISABLED)
}
expectResponseCode(response)
.withStatusCodeErrorMapping({
400: AuthError.LOGIN_DISABLED,
401: AuthError.INVALID_CREDENTIALS
})
.sendRequest()
}

33
src/api/auth/types.ts Normal file
View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum AuthError {
INVALID_CREDENTIALS = 'invalidCredentials',
LOGIN_DISABLED = 'loginDisabled',
OPENID_ERROR = 'openIdError',
OTHER = 'other'
}
export enum RegisterError {
USERNAME_EXISTING = 'usernameExisting',
REGISTRATION_DISABLED = 'registrationDisabled',
OTHER = 'other'
}
export interface LoginDto {
username: string
password: string
}
export interface RegisterDto {
username: string
password: string
displayName: string
}
export interface ChangePasswordDto {
currentPassword: string
newPassword: string
}

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiRequestBuilder } from './api-request-builder'
/**
* Builder to construct and execute a call to the HTTP API that contains a body payload.
*
* @param RequestBodyType The type of the request body if applicable.
*/
export abstract class ApiRequestBuilderWithBody<ResponseType, RequestBodyType> extends ApiRequestBuilder<ResponseType> {
/**
* Adds a body part to the API request. If this is called multiple times, only the body of the last invocation will be
* used during the execution of the request.
*
* @param bodyData The data to use as request body.
* @return The API request instance itself for chaining.
*/
withBody(bodyData: BodyInit): this {
this.requestBody = bodyData
return this
}
/**
* Adds a JSON-encoded body part to the API request. This method will set the content-type header appropriately.
*
* @param bodyData The data to use as request body. Will get stringified to JSON.
* @return The API request instance itself for chaining.
* @see {withBody}
*/
withJsonBody(bodyData: RequestBodyType): this {
this.withHeader('Content-Type', 'application/json')
return this.withBody(JSON.stringify(bodyData))
}
}

View file

@ -0,0 +1,115 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { apiUrl } from '../../../utils/api-url'
import deepmerge from 'deepmerge'
import { defaultConfig, defaultHeaders } from '../default-config'
import { ApiResponse } from '../api-response'
/**
* Builder to construct and execute a call to the HTTP API.
*
* @param ResponseType The type of the response if applicable.
*/
export abstract class ApiRequestBuilder<ResponseType> {
private readonly targetUrl: string
private overrideExpectedResponseStatus: number | undefined
private customRequestOptions = defaultConfig
private customRequestHeaders = new Headers(defaultHeaders)
private customStatusCodeErrorMapping: Record<number, string> | undefined
protected requestBody: BodyInit | undefined
/**
* Initializes a new API call with the default request options.
*
* @param endpoint The target endpoint without a leading slash.
*/
constructor(endpoint: string) {
this.targetUrl = apiUrl + endpoint
}
protected async sendRequestAndVerifyResponse(
httpMethod: RequestInit['method'],
defaultExpectedStatus: number
): Promise<ApiResponse<ResponseType>> {
const response = await fetch(this.targetUrl, {
...this.customRequestOptions,
method: httpMethod,
headers: this.customRequestHeaders,
body: this.requestBody
})
if (this.customStatusCodeErrorMapping && this.customStatusCodeErrorMapping[response.status]) {
throw new Error(this.customStatusCodeErrorMapping[response.status])
}
const expectedStatus = this.overrideExpectedResponseStatus
? this.overrideExpectedResponseStatus
: defaultExpectedStatus
if (response.status !== expectedStatus) {
throw new Error(`Expected response status code ${expectedStatus} but received ${response.status}.`)
}
return new ApiResponse(response)
}
/**
* Adds an HTTP header to the API request. Previous headers with the same name will get overridden on subsequent calls
* with the same name.
*
* @param name The name of the HTTP header to add. Example: 'Content-Type'
* @param value The value of the HTTP header to add. Example: 'text/markdown'
* @return The API request instance itself for chaining.
*/
withHeader(name: string, value: string): this {
this.customRequestHeaders.set(name, value)
return this
}
/**
* Adds custom request options for the underlying fetch request by merging them with the existing options.
*
* @param options The options to set for the fetch request.
* @return The API request instance itself for chaining.
*/
withCustomOptions(options: Partial<Omit<RequestInit, 'method' | 'headers' | 'body'>>): this {
this.customRequestOptions = deepmerge(this.customRequestOptions, options)
return this
}
/**
* Adds a mapping from response status codes to error messages. An error with the specified message will be thrown
* when the status code of the response matches one of the defined ones.
*
* @param mapping The mapping from response status codes to error messages.
* @return The API request instance itself for chaining.
*/
withStatusCodeErrorMapping(mapping: Record<number, string>): this {
this.customStatusCodeErrorMapping = mapping
return this
}
/**
* Sets the expected status code of the response. Can be used to override the default expected status code.
* An error will be thrown when the status code of the response does not match the expected one.
*
* @param expectedCode The expected status code of the response.
* @return The API request instance itself for chaining.
*/
withExpectedStatusCode(expectedCode: number): this {
this.overrideExpectedResponseStatus = expectedCode
return this
}
/**
* Send the prepared API call as a GET request. A default status code of 200 is expected.
*
* @return The API response.
* @throws Error when the status code does not match the expected one or is defined as in the custom status code
* error mapping.
*/
abstract sendRequest(): Promise<ApiResponse<ResponseType>>
}

View file

@ -0,0 +1,170 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { expectFetch } from './test-utils/expect-fetch'
import { DeleteApiRequestBuilder } from './delete-api-request-builder'
describe('DeleteApiRequestBuilder', () => {
let originalFetch: typeof global['fetch']
beforeAll(() => {
originalFetch = global.fetch
})
afterAll(() => {
global.fetch = originalFetch
})
describe('sendRequest without body', () => {
it('without headers', async () => {
expectFetch('/api/mock-backend/private/test', 204, { method: 'DELETE' })
await new DeleteApiRequestBuilder<string, undefined>('test').sendRequest()
})
it('with single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
headers: expectedHeaders
})
await new DeleteApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
})
it('with overriding single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'false')
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
headers: expectedHeaders
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test', 'false')
.sendRequest()
})
it('with multiple different headers', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectedHeaders.append('test2', 'false')
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
headers: expectedHeaders
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test2', 'false')
.sendRequest()
})
})
it('sendRequest with JSON body', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('Content-Type', 'application/json')
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
headers: expectedHeaders,
body: '{"test":true,"foo":"bar"}'
})
await new DeleteApiRequestBuilder('test')
.withJsonBody({
test: true,
foo: 'bar'
})
.sendRequest()
})
it('sendRequest with other body', async () => {
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
body: 'HedgeDoc'
})
await new DeleteApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
})
it('sendRequest with expected status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'DELETE' })
await new DeleteApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
})
describe('sendRequest with custom options', () => {
it('with one option', async () => {
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
cache: 'force-cache'
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.sendRequest()
})
it('overriding single option', async () => {
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
cache: 'no-store'
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.withCustomOptions({
cache: 'no-store'
})
.sendRequest()
})
it('with multiple options', async () => {
expectFetch('/api/mock-backend/private/test', 204, {
method: 'DELETE',
cache: 'force-cache',
integrity: 'test'
})
await new DeleteApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache',
integrity: 'test'
})
.sendRequest()
})
})
describe('sendRequest with custom error map', () => {
it('for valid status code', async () => {
expectFetch('/api/mock-backend/private/test', 204, { method: 'DELETE' })
await new DeleteApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
})
it('for invalid status code 1', async () => {
expectFetch('/api/mock-backend/private/test', 400, { method: 'DELETE' })
const request = new DeleteApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('noooooo')
})
it('for invalid status code 2', async () => {
expectFetch('/api/mock-backend/private/test', 401, { method: 'DELETE' })
const request = new DeleteApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('not you!')
})
})
})

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ApiResponse } from '../api-response'
import { ApiRequestBuilderWithBody } from './api-request-builder-with-body'
/**
* Builder to construct a DELETE request to the API.
*
* @param ResponseType The type of the expected response. Defaults to no response body.
* @param RequestBodyType The type of the request body. Defaults to no request body.
* @see {ApiRequestBuilder}
*/
export class DeleteApiRequestBuilder<ResponseType = void, RequestBodyType = unknown> extends ApiRequestBuilderWithBody<
ResponseType,
RequestBodyType
> {
/**
* @see {ApiRequestBuilder#sendRequest}
*/
sendRequest(): Promise<ApiResponse<ResponseType>> {
return this.sendRequestAndVerifyResponse('DELETE', 204)
}
}

View file

@ -0,0 +1,146 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { expectFetch } from './test-utils/expect-fetch'
import { GetApiRequestBuilder } from './get-api-request-builder'
describe('GetApiRequestBuilder', () => {
let originalFetch: typeof global['fetch']
beforeAll(() => {
originalFetch = global.fetch
})
afterAll(() => {
global.fetch = originalFetch
})
describe('sendRequest', () => {
it('without headers', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'GET' })
await new GetApiRequestBuilder<string>('test').sendRequest()
})
it('with single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
headers: expectedHeaders
})
await new GetApiRequestBuilder<string>('test').withHeader('test', 'true').sendRequest()
})
it('with overriding single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'false')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
headers: expectedHeaders
})
await new GetApiRequestBuilder<string>('test')
.withHeader('test', 'true')
.withHeader('test', 'false')
.sendRequest()
})
it('with multiple different headers', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectedHeaders.append('test2', 'false')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
headers: expectedHeaders
})
await new GetApiRequestBuilder<string>('test')
.withHeader('test', 'true')
.withHeader('test2', 'false')
.sendRequest()
})
})
it('sendRequest with expected status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'GET' })
await new GetApiRequestBuilder<string>('test').withExpectedStatusCode(200).sendRequest()
})
describe('sendRequest with custom options', () => {
it('with one option', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
cache: 'force-cache'
})
await new GetApiRequestBuilder<string>('test')
.withCustomOptions({
cache: 'force-cache'
})
.sendRequest()
})
it('overriding single option', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
cache: 'no-store'
})
await new GetApiRequestBuilder<string>('test')
.withCustomOptions({
cache: 'force-cache'
})
.withCustomOptions({
cache: 'no-store'
})
.sendRequest()
})
it('with multiple options', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'GET',
cache: 'force-cache',
integrity: 'test'
})
await new GetApiRequestBuilder<string>('test')
.withCustomOptions({
cache: 'force-cache',
integrity: 'test'
})
.sendRequest()
})
})
describe('sendRequest with custom error map', () => {
it('for valid status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'GET' })
await new GetApiRequestBuilder<string>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
})
it('for invalid status code 1', async () => {
expectFetch('/api/mock-backend/private/test', 400, { method: 'GET' })
const request = new GetApiRequestBuilder<string>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('noooooo')
})
it('for invalid status code 2', async () => {
expectFetch('/api/mock-backend/private/test', 401, { method: 'GET' })
const request = new GetApiRequestBuilder<string>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('not you!')
})
})
})

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiRequestBuilder } from './api-request-builder'
import type { ApiResponse } from '../api-response'
/**
* Builder to construct a GET request to the API.
*
* @param ResponseType The type of the expected response.
* @see {ApiRequestBuilder}
*/
export class GetApiRequestBuilder<ResponseType> extends ApiRequestBuilder<ResponseType> {
/**
* @see {ApiRequestBuilder#sendRequest}
*/
sendRequest(): Promise<ApiResponse<ResponseType>> {
return this.sendRequestAndVerifyResponse('GET', 200)
}
}

View file

@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PostApiRequestBuilder } from './post-api-request-builder'
import { expectFetch } from './test-utils/expect-fetch'
describe('PostApiRequestBuilder', () => {
let originalFetch: typeof global['fetch']
beforeAll(() => {
originalFetch = global.fetch
})
afterAll(() => {
global.fetch = originalFetch
})
describe('sendRequest without body', () => {
it('without headers', async () => {
expectFetch('/api/mock-backend/private/test', 201, { method: 'POST' })
await new PostApiRequestBuilder<string, undefined>('test').sendRequest()
})
it('with single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
headers: expectedHeaders
})
await new PostApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
})
it('with overriding single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'false')
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
headers: expectedHeaders
})
await new PostApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test', 'false')
.sendRequest()
})
it('with multiple different headers', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectedHeaders.append('test2', 'false')
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
headers: expectedHeaders
})
await new PostApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test2', 'false')
.sendRequest()
})
})
it('sendRequest with JSON body', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('Content-Type', 'application/json')
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
headers: expectedHeaders,
body: '{"test":true,"foo":"bar"}'
})
await new PostApiRequestBuilder('test')
.withJsonBody({
test: true,
foo: 'bar'
})
.sendRequest()
})
it('sendRequest with other body', async () => {
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
body: 'HedgeDoc'
})
await new PostApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
})
it('sendRequest with expected status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'POST' })
await new PostApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
})
describe('sendRequest with custom options', () => {
it('with one option', async () => {
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
cache: 'force-cache'
})
await new PostApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.sendRequest()
})
it('overriding single option', async () => {
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
cache: 'no-store'
})
await new PostApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.withCustomOptions({
cache: 'no-store'
})
.sendRequest()
})
it('with multiple options', async () => {
expectFetch('/api/mock-backend/private/test', 201, {
method: 'POST',
cache: 'force-cache',
integrity: 'test'
})
await new PostApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache',
integrity: 'test'
})
.sendRequest()
})
})
describe('sendRequest with custom error map', () => {
it('for valid status code', async () => {
expectFetch('/api/mock-backend/private/test', 201, { method: 'POST' })
await new PostApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
})
it('for invalid status code 1', async () => {
expectFetch('/api/mock-backend/private/test', 400, { method: 'POST' })
const request = new PostApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('noooooo')
})
it('for invalid status code 2', async () => {
expectFetch('/api/mock-backend/private/test', 401, { method: 'POST' })
const request = new PostApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('not you!')
})
})
})

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ApiResponse } from '../api-response'
import { ApiRequestBuilderWithBody } from './api-request-builder-with-body'
/**
* Builder to construct a POST request to the API.
*
* @param ResponseType The type of the expected response.
* @param RequestBodyType The type of the request body
* @see {ApiRequestBuilder}
*/
export class PostApiRequestBuilder<ResponseType, RequestBodyType> extends ApiRequestBuilderWithBody<
ResponseType,
RequestBodyType
> {
/**
* @see {ApiRequestBuilder#sendRequest}
*/
sendRequest(): Promise<ApiResponse<ResponseType>> {
return this.sendRequestAndVerifyResponse('POST', 201)
}
}

View file

@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { expectFetch } from './test-utils/expect-fetch'
import { PutApiRequestBuilder } from './put-api-request-builder'
describe('PutApiRequestBuilder', () => {
let originalFetch: typeof global['fetch']
beforeAll(() => {
originalFetch = global.fetch
})
afterAll(() => {
global.fetch = originalFetch
})
describe('sendRequest without body', () => {
it('without headers', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'PUT' })
await new PutApiRequestBuilder<string, undefined>('test').sendRequest()
})
it('with single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
headers: expectedHeaders
})
await new PutApiRequestBuilder<string, undefined>('test').withHeader('test', 'true').sendRequest()
})
it('with overriding single header', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'false')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
headers: expectedHeaders
})
await new PutApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test', 'false')
.sendRequest()
})
it('with multiple different headers', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('test', 'true')
expectedHeaders.append('test2', 'false')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
headers: expectedHeaders
})
await new PutApiRequestBuilder<string, undefined>('test')
.withHeader('test', 'true')
.withHeader('test2', 'false')
.sendRequest()
})
})
it('sendRequest with JSON body', async () => {
const expectedHeaders = new Headers()
expectedHeaders.append('Content-Type', 'application/json')
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
headers: expectedHeaders,
body: '{"test":true,"foo":"bar"}'
})
await new PutApiRequestBuilder('test')
.withJsonBody({
test: true,
foo: 'bar'
})
.sendRequest()
})
it('sendRequest with other body', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
body: 'HedgeDoc'
})
await new PutApiRequestBuilder('test').withBody('HedgeDoc').sendRequest()
})
it('sendRequest with expected status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'PUT' })
await new PutApiRequestBuilder<string, undefined>('test').withExpectedStatusCode(200).sendRequest()
})
describe('sendRequest with custom options', () => {
it('with one option', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
cache: 'force-cache'
})
await new PutApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.sendRequest()
})
it('overriding single option', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
cache: 'no-store'
})
await new PutApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache'
})
.withCustomOptions({
cache: 'no-store'
})
.sendRequest()
})
it('with multiple options', async () => {
expectFetch('/api/mock-backend/private/test', 200, {
method: 'PUT',
cache: 'force-cache',
integrity: 'test'
})
await new PutApiRequestBuilder<string, undefined>('test')
.withCustomOptions({
cache: 'force-cache',
integrity: 'test'
})
.sendRequest()
})
})
describe('sendRequest with custom error map', () => {
it('for valid status code', async () => {
expectFetch('/api/mock-backend/private/test', 200, { method: 'PUT' })
await new PutApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
})
it('for invalid status code 1', async () => {
expectFetch('/api/mock-backend/private/test', 400, { method: 'PUT' })
const request = new PutApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('noooooo')
})
it('for invalid status code 2', async () => {
expectFetch('/api/mock-backend/private/test', 401, { method: 'PUT' })
const request = new PutApiRequestBuilder<string, undefined>('test')
.withStatusCodeErrorMapping({
400: 'noooooo',
401: 'not you!'
})
.sendRequest()
await expect(request).rejects.toThrow('not you!')
})
})
})

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ApiResponse } from '../api-response'
import { ApiRequestBuilderWithBody } from './api-request-builder-with-body'
/**
* Builder to construct a PUT request to the API.
*
* @param ResponseType The type of the expected response.
* @param RequestBodyType The type of the request body
* @see {ApiRequestBuilder}
*/
export class PutApiRequestBuilder<ResponseType, RequestBodyType> extends ApiRequestBuilderWithBody<
ResponseType,
RequestBodyType
> {
/**
* @see {ApiRequestBuilder#sendRequest}
*/
sendRequest(): Promise<ApiResponse<ResponseType>> {
return this.sendRequestAndVerifyResponse('PUT', 200)
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defaultConfig } from '../../default-config'
import { Mock } from 'ts-mockery'
export const expectFetch = (expectedUrl: string, expectedStatusCode: number, expectedOptions: RequestInit): void => {
global.fetch = jest.fn((fetchUrl: RequestInfo, fetchOptions?: RequestInit): Promise<Response> => {
expect(fetchUrl).toEqual(expectedUrl)
expect(fetchOptions).toStrictEqual({
...defaultConfig,
body: undefined,
headers: new Headers(),
...expectedOptions
})
return Promise.resolve(
Mock.of<Response>({
status: expectedStatusCode
})
)
})
}

View file

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Mock } from 'ts-mockery'
import { ApiResponse } from './api-response'
describe('ApiResponse', () => {
it('getResponse returns input response', () => {
const mockResponse = Mock.of<Response>()
const responseObj = new ApiResponse(mockResponse)
expect(responseObj.getResponse()).toEqual(mockResponse)
})
it('asBlob', async () => {
const mockBlob = Mock.of<Blob>()
const mockResponse = Mock.of<Response>({
blob(): Promise<Blob> {
return Promise.resolve(mockBlob)
}
})
const responseObj = new ApiResponse(mockResponse)
await expect(responseObj.asBlob()).resolves.toEqual(mockBlob)
})
describe('asParsedJsonObject with', () => {
it('invalid header', async () => {
const mockHeaders = new Headers()
mockHeaders.set('Content-Type', 'text/invalid')
const mockResponse = Mock.of<Response>({
headers: mockHeaders
})
const responseObj = new ApiResponse(mockResponse)
await expect(responseObj.asParsedJsonObject()).rejects.toThrow('Response body does not seem to be JSON encoded')
})
it('valid header', async () => {
const mockHeaders = new Headers()
mockHeaders.set('Content-Type', 'application/json')
const mockBody = {
Hedgy: '🦔'
}
const mockResponse = Mock.of<Response>({
headers: mockHeaders,
json(): Promise<typeof mockBody> {
return Promise.resolve(mockBody)
}
})
const responseObj = new ApiResponse(mockResponse)
await expect(responseObj.asParsedJsonObject()).resolves.toEqual(mockBody)
})
})
})

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Class that represents the response of an {@link ApiRequestBuilder}.
*/
export class ApiResponse<ResponseType> {
private readonly response: Response
/**
* Initializes a new API response instance based on an HTTP response.
* @param response The HTTP response from the fetch call.
*/
constructor(response: Response) {
this.response = response
}
/**
* Returns the raw response from the fetch call.
*
* @return The response from the fetch call.
*/
getResponse(): Response {
return this.response
}
/**
* Returns the response as parsed JSON. An error will be thrown if the response is not JSON encoded.
*
* @return The parsed JSON response.
* @throws Error if the response is not JSON encoded.
*/
async asParsedJsonObject(): Promise<ResponseType> {
if (!this.response.headers.get('Content-Type')?.startsWith('application/json')) {
throw new Error('Response body does not seem to be JSON encoded.')
}
// TODO Responses should better be type validated
// see https://github.com/hedgedoc/react-client/issues/1219
return (await this.response.json()) as ResponseType
}
/**
* Returns the response as a Blob.
*
* @return The response body as a blob.
*/
async asBlob(): Promise<Blob> {
return await this.response.blob()
}
}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const defaultHeaders: HeadersInit = {}
export const defaultConfig: Partial<RequestInit> = {
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
redirect: 'follow',
referrerPolicy: 'no-referrer'
}

View file

@ -1,16 +1,17 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import type { Config } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
/**
* Fetches the frontend config from the backend.
* @return The frontend config.
*/
export const getConfig = async (): Promise<Config> => {
const response = await fetch(getApiUrl() + 'config', {
...defaultFetchConfig
})
expectResponseCode(response)
return (await response.json()) as Promise<Config>
const response = await new GetApiRequestBuilder<Config>('config').sendRequest()
return response.asParsedJsonObject()
}

View file

@ -1,62 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Config {
allowAnonymous: boolean
allowRegister: boolean
authProviders: AuthProvidersState
branding: BrandingConfig
customAuthNames: CustomAuthNames
useImageProxy: boolean
specialUrls: SpecialUrls
version: BackendVersion
plantumlServer: string | null
maxDocumentLength: number
iframeCommunication: iframeCommunicationConfig
}
export interface iframeCommunicationConfig {
editorOrigin: string
rendererOrigin: string
}
export interface BrandingConfig {
name: string
logo: string
}
export interface BackendVersion {
major: number
minor: number
patch: number
preRelease?: string
commit?: string
}
export interface AuthProvidersState {
facebook: boolean
github: boolean
twitter: boolean
gitlab: boolean
dropbox: boolean
ldap: boolean
google: boolean
saml: boolean
oauth2: boolean
local: boolean
}
export interface CustomAuthNames {
ldap: string
oauth2: string
saml: string
}
export interface SpecialUrls {
privacy?: string
termsOfUse?: string
imprint?: string
}

92
src/api/config/types.ts Normal file
View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Config {
allowAnonymous: boolean
allowRegister: boolean
authProviders: AuthProvider[]
branding: BrandingConfig
useImageProxy: boolean
specialUrls: SpecialUrls
version: BackendVersion
plantumlServer?: string
maxDocumentLength: number
iframeCommunication: iframeCommunicationConfig
}
export enum AuthProviderType {
DROPBOX = 'dropbox',
FACEBOOK = 'facebook',
GITHUB = 'github',
GOOGLE = 'google',
TWITTER = 'twitter',
GITLAB = 'gitlab',
OAUTH2 = 'oauth2',
LDAP = 'ldap',
SAML = 'saml',
LOCAL = 'local'
}
export type AuthProviderTypeWithCustomName =
| AuthProviderType.GITLAB
| AuthProviderType.OAUTH2
| AuthProviderType.LDAP
| AuthProviderType.SAML
export type AuthProviderTypeWithoutCustomName =
| AuthProviderType.DROPBOX
| AuthProviderType.FACEBOOK
| AuthProviderType.GITHUB
| AuthProviderType.GOOGLE
| AuthProviderType.TWITTER
| AuthProviderType.LOCAL
export const authProviderTypeOneClick = [
AuthProviderType.DROPBOX,
AuthProviderType.FACEBOOK,
AuthProviderType.GITHUB,
AuthProviderType.GITLAB,
AuthProviderType.GOOGLE,
AuthProviderType.OAUTH2,
AuthProviderType.SAML,
AuthProviderType.TWITTER
]
export interface AuthProviderWithCustomName {
type: AuthProviderTypeWithCustomName
identifier: string
providerName: string
}
export interface AuthProviderWithoutCustomName {
type: AuthProviderTypeWithoutCustomName
}
export type AuthProvider = AuthProviderWithCustomName | AuthProviderWithoutCustomName
export interface iframeCommunicationConfig {
editorOrigin: string
rendererOrigin: string
}
export interface BrandingConfig {
name?: string
logo?: string
}
export interface BackendVersion {
major: number
minor: number
patch: number
preRelease?: string
commit?: string
}
export interface SpecialUrls {
privacy?: string
termsOfUse?: string
imprint?: string
}

18
src/api/group/index.ts Normal file
View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { GroupInfo } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
/**
* Retrieves information about a group with a given name.
* @param groupName The name of the group.
* @return Information about the group.
*/
export const getGroup = async (groupName: string): Promise<GroupInfo> => {
const response = await new GetApiRequestBuilder<GroupInfo>('groups/' + groupName).sendRequest()
return response.asParsedJsonObject()
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface GroupInfoDto {
export interface GroupInfo {
name: string
displayName: string
special: boolean

View file

@ -3,32 +3,20 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { HistoryEntry, HistoryEntryPutDto, HistoryEntryWithOrigin } from './types'
import { HistoryEntryOrigin } from './types'
import type { HistoryEntry } from '../../redux/history/types'
import { HistoryEntryOrigin } from '../../redux/history/types'
import type { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types'
export const historyEntryDtoToHistoryEntry = (entryDto: HistoryEntryDto): HistoryEntry => {
export const addRemoteOriginToHistoryEntry = (entryDto: HistoryEntry): HistoryEntryWithOrigin => {
return {
origin: HistoryEntryOrigin.REMOTE,
title: entryDto.title,
pinStatus: entryDto.pinStatus,
identifier: entryDto.identifier,
tags: entryDto.tags,
lastVisited: entryDto.lastVisited
...entryDto,
origin: HistoryEntryOrigin.REMOTE
}
}
export const historyEntryToHistoryEntryPutDto = (entry: HistoryEntry): HistoryEntryPutDto => {
return {
pinStatus: entry.pinStatus,
lastVisited: entry.lastVisited,
lastVisitedAt: entry.lastVisitedAt,
note: entry.identifier
}
}
export const historyEntryToHistoryEntryUpdateDto = (entry: HistoryEntry): HistoryEntryUpdateDto => {
return {
pinStatus: entry.pinStatus
}
}

View file

@ -1,50 +1,59 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ChangePinStatusDto, HistoryEntry, HistoryEntryPutDto } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import type { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types'
export const getHistory = async (): Promise<HistoryEntryDto[]> => {
const response = await fetch(getApiUrl() + 'me/history', {
...defaultFetchConfig
})
expectResponseCode(response)
return (await response.json()) as Promise<HistoryEntryDto[]>
/**
* Fetches the remote history for the user from the server.
* @return The remote history entries of the user.
*/
export const getRemoteHistory = async (): Promise<HistoryEntry[]> => {
const response = await new GetApiRequestBuilder<HistoryEntry[]>('me/history').sendRequest()
return response.asParsedJsonObject()
}
export const postHistory = async (entries: HistoryEntryPutDto[]): Promise<void> => {
const response = await fetch(getApiUrl() + 'me/history', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify(entries)
})
expectResponseCode(response)
/**
* Replaces the remote history of the user with the given history entries.
* @param entries The history entries to store remotely.
*/
export const setRemoteHistoryEntries = async (entries: HistoryEntryPutDto[]): Promise<void> => {
await new PostApiRequestBuilder<void, HistoryEntryPutDto[]>('me/history').withJsonBody(entries).sendRequest()
}
export const updateHistoryEntryPinStatus = async (noteId: string, entry: HistoryEntryUpdateDto): Promise<void> => {
const response = await fetch(getApiUrl() + 'me/history/' + noteId, {
...defaultFetchConfig,
method: 'PUT',
body: JSON.stringify(entry)
})
expectResponseCode(response)
/**
* Updates a remote history entry's pin state.
* @param noteIdOrAlias The note id for which to update the pinning state.
* @param pinStatus True when the note should be pinned, false otherwise.
*/
export const updateRemoteHistoryEntryPinStatus = async (
noteIdOrAlias: string,
pinStatus: boolean
): Promise<HistoryEntry> => {
const response = await new PutApiRequestBuilder<HistoryEntry, ChangePinStatusDto>('me/history/' + noteIdOrAlias)
.withJsonBody({
pinStatus
})
.sendRequest()
return response.asParsedJsonObject()
}
export const deleteHistoryEntry = async (noteId: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'me/history/' + noteId, {
...defaultFetchConfig,
method: 'DELETE'
})
expectResponseCode(response)
/**
* Deletes a remote history entry.
* @param noteIdOrAlias The note id or alias of the history entry to remove.
*/
export const deleteRemoteHistoryEntry = async (noteIdOrAlias: string): Promise<void> => {
await new DeleteApiRequestBuilder('me/history/' + noteIdOrAlias).sendRequest()
}
export const deleteHistory = async (): Promise<void> => {
const response = await fetch(getApiUrl() + 'me/history', {
...defaultFetchConfig,
method: 'DELETE'
})
expectResponseCode(response)
/**
* Deletes the complete remote history.
*/
export const deleteRemoteHistory = async (): Promise<void> => {
await new DeleteApiRequestBuilder('me/history').sendRequest()
}

View file

@ -1,23 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface HistoryEntryPutDto {
note: string
pinStatus: boolean
lastVisited: string
}
export interface HistoryEntryUpdateDto {
pinStatus: boolean
}
export interface HistoryEntryDto {
identifier: string
title: string
lastVisited: string
tags: string[]
pinStatus: boolean
}

31
src/api/history/types.ts Normal file
View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum HistoryEntryOrigin {
LOCAL = 'local',
REMOTE = 'remote'
}
export interface HistoryEntryPutDto {
note: string
pinStatus: boolean
lastVisitedAt: string
}
export interface HistoryEntry {
identifier: string
title: string
lastVisitedAt: string
tags: string[]
pinStatus: boolean
}
export interface HistoryEntryWithOrigin extends HistoryEntry {
origin: HistoryEntryOrigin
}
export interface ChangePinStatusDto {
pinStatus: boolean
}

View file

@ -1,43 +1,48 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { UserInfoDto } from '../users/types'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import { isMockMode } from '../../utils/test-modes'
import type { MediaUpload } from '../media/types'
import type { ChangeDisplayNameDto, LoginUserInfo } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
/**
* Returns metadata about the currently signed-in user from the API.
* @throws Error when the user is not signed-in.
* @return The user metadata.
*/
export const getMe = async (): Promise<UserInfoDto> => {
const response = await fetch(getApiUrl() + `me${isMockMode() ? '-get' : ''}`, {
...defaultFetchConfig
})
expectResponseCode(response)
return (await response.json()) as UserInfoDto
}
export const updateDisplayName = async (displayName: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'me', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
name: displayName
})
})
expectResponseCode(response)
export const getMe = async (): Promise<LoginUserInfo> => {
const response = await new GetApiRequestBuilder<LoginUserInfo>('me').sendRequest()
return response.asParsedJsonObject()
}
/**
* Deletes the current user from the server.
*/
export const deleteUser = async (): Promise<void> => {
const response = await fetch(getApiUrl() + 'me', {
...defaultFetchConfig,
method: 'DELETE'
})
expectResponseCode(response)
await new DeleteApiRequestBuilder('me').sendRequest()
}
/**
* Changes the display name of the current user.
* @param displayName The new display name to set.
*/
export const updateDisplayName = async (displayName: string): Promise<void> => {
await new PostApiRequestBuilder<void, ChangeDisplayNameDto>('me/profile')
.withJsonBody({
displayName
})
.sendRequest()
}
/**
* Retrieves a list of media belonging to the user.
* @return List of media object information.
*/
export const getMyMedia = async (): Promise<MediaUpload[]> => {
const response = await new GetApiRequestBuilder<MediaUpload[]>('me/media').sendRequest()
return response.asParsedJsonObject()
}

15
src/api/me/types.ts Normal file
View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { UserInfo } from '../users/types'
export interface LoginUserInfo extends UserInfo {
authProvider: string
email: string
}
export interface ChangeDisplayNameDto {
displayName: string
}

View file

@ -1,49 +1,47 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ImageProxyRequestDto, ImageProxyResponse, MediaUpload } from './types'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { isMockMode, isTestMode } from '../../utils/test-modes'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
export interface ImageProxyResponse {
src: string
}
/**
* Requests an image-proxy URL from the backend for a given image URL.
* @param imageUrl The image URL which should be proxied.
* @return The proxy URL for the image.
*/
export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyResponse> => {
const response = await fetch(getApiUrl() + 'media/proxy', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
src: imageUrl
const response = await new PostApiRequestBuilder<ImageProxyResponse, ImageProxyRequestDto>('media/proxy')
.withJsonBody({
url: imageUrl
})
})
expectResponseCode(response)
return (await response.json()) as Promise<ImageProxyResponse>
.sendRequest()
return response.asParsedJsonObject()
}
export interface UploadedMedia {
link: string
/**
* Uploads a media file to the backend.
* @param noteIdOrAlias The id or alias of the note from which the media is uploaded.
* @param media The binary media content.
* @return The URL of the uploaded media object.
*/
export const uploadFile = async (noteIdOrAlias: string, media: Blob): Promise<MediaUpload> => {
const postData = new FormData()
postData.append('file', media)
const response = await new PostApiRequestBuilder<MediaUpload, void>('media')
.withHeader('Content-Type', 'multipart/form-data')
.withHeader('HedgeDoc-Note', noteIdOrAlias)
.withBody(postData)
.sendRequest()
return response.asParsedJsonObject()
}
export const uploadFile = async (noteId: string, media: Blob): Promise<UploadedMedia> => {
const response = await fetch(`${getApiUrl()}media/upload${isMockMode() ? '-post' : ''}`, {
...defaultFetchConfig,
headers: {
'Content-Type': media.type,
'HedgeDoc-Note': noteId
},
method: isMockMode() ? 'GET' : 'POST',
body: isMockMode() ? undefined : media
})
if (isMockMode() && !isTestMode()) {
await new Promise((resolve) => {
setTimeout(resolve, 3000)
})
}
expectResponseCode(response, isMockMode() ? 200 : 201)
return (await response.json()) as Promise<UploadedMedia>
/**
* Deletes some uploaded media object.
* @param mediaId The identifier of the media object to delete.
*/
export const deleteUploadedMedia = async (mediaId: string): Promise<void> => {
await new DeleteApiRequestBuilder('media/' + mediaId).sendRequest()
}

19
src/api/media/types.ts Normal file
View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface MediaUpload {
url: string
noteId: string | null
createdAt: string
username: string
}
export interface ImageProxyResponse {
url: string
}
export interface ImageProxyRequestDto {
url: string
}

View file

@ -1,27 +1,65 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Note } from './types'
import type { MediaUpload } from '../media/types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import type { NoteDto } from './types'
import { isMockMode } from '../../utils/test-modes'
export const getNote = async (noteId: string): Promise<NoteDto> => {
// The "-get" suffix is necessary, because in our mock api (filesystem) the note id might already be a folder.
// TODO: [mrdrogdrog] replace -get with actual api route as soon as api backend is ready.
const response = await fetch(getApiUrl() + `notes/${noteId}${isMockMode() ? '-get' : ''}`, {
...defaultFetchConfig
})
expectResponseCode(response)
return (await response.json()) as Promise<NoteDto>
/**
* Retrieves the content and metadata about the specified note.
* @param noteIdOrAlias The id or alias of the note.
* @return Content and metadata of the specified note.
*/
export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias).sendRequest()
return response.asParsedJsonObject()
}
export const deleteNote = async (noteId: string): Promise<void> => {
const response = await fetch(getApiUrl() + `notes/${noteId}`, {
...defaultFetchConfig,
method: 'DELETE'
})
expectResponseCode(response)
/**
* Returns a list of media objects associated with the specified note.
* @param noteIdOrAlias The id or alias of the note.
* @return List of media object metadata associated with specified note.
*/
export const getMediaForNote = async (noteIdOrAlias: string): Promise<MediaUpload[]> => {
const response = await new GetApiRequestBuilder<MediaUpload[]>(`notes/${noteIdOrAlias}/media`).sendRequest()
return response.asParsedJsonObject()
}
/**
* Creates a new note with a given markdown content.
* @param markdown The content of the new note.
* @return Content and metadata of the new note.
*/
export const createNote = async (markdown: string): Promise<Note> => {
const response = await new PostApiRequestBuilder<Note, void>('notes')
.withHeader('Content-Type', 'text/markdown')
.withBody(markdown)
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Creates a new note with a given markdown content and a defined primary alias.
* @param markdown The content of the new note.
* @param primaryAlias The primary alias of the new note.
* @return Content and metadata of the new note.
*/
export const createNoteWithPrimaryAlias = async (markdown: string, primaryAlias: string): Promise<Note> => {
const response = await new PostApiRequestBuilder<Note, void>('notes/' + primaryAlias)
.withHeader('Content-Type', 'text/markdown')
.withBody(markdown)
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Deletes the specified note.
* @param noteIdOrAlias The id or alias of the note to delete.
*/
export const deleteNote = async (noteIdOrAlias: string): Promise<void> => {
await new DeleteApiRequestBuilder('notes/' + noteIdOrAlias).sendRequest()
}

View file

@ -1,53 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { UserInfoDto } from '../users/types'
import type { GroupInfoDto } from '../group/types'
export interface NoteDto {
content: string
metadata: NoteMetadataDto
editedByAtPosition: NoteAuthorshipDto[]
}
export interface NoteMetadataDto {
id: string
alias: string
version: number
title: string
description: string
tags: string[]
updateTime: string
updateUser: UserInfoDto
viewCount: number
createTime: string
editedBy: string[]
permissions: NotePermissionsDto
}
export interface NoteAuthorshipDto {
userName: string
startPos: number
endPos: number
createdAt: string
updatedAt: string
}
export interface NotePermissionsDto {
owner: UserInfoDto
sharedToUsers: NoteUserPermissionEntryDto[]
sharedToGroups: NoteGroupPermissionEntryDto[]
}
export interface NoteUserPermissionEntryDto {
user: UserInfoDto
canEdit: boolean
}
export interface NoteGroupPermissionEntryDto {
group: GroupInfoDto
canEdit: boolean
}

52
src/api/notes/types.ts Normal file
View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Alias } from '../alias/types'
export interface Note {
content: string
metadata: NoteMetadata
editedByAtPosition: NoteEdit[]
}
export interface NoteMetadata {
id: string
aliases: Alias[]
primaryAddress: string
title: string
description: string
tags: string[]
updatedAt: string
updateUsername: string | null
viewCount: number
createdAt: string
editedBy: string[]
permissions: NotePermissions
version: number
}
export interface NoteEdit {
username: string | null
startPos: number
endPos: number
createdAt: string
updatedAt: string
}
export interface NotePermissions {
owner: string | null
sharedToUsers: NoteUserPermissionEntry[]
sharedToGroups: NoteGroupPermissionEntry[]
}
export interface NoteUserPermissionEntry {
username: string
canEdit: boolean
}
export interface NoteGroupPermissionEntry {
groupName: string
canEdit: boolean
}

View file

@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NotePermissions } from '../notes/types'
import type { OwnerChangeDto, PermissionSetDto } from './types'
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
/**
* Sets the owner of a note.
* @param noteId The id of the note.
* @param owner The username of the new owner.
* @return The updated note permissions.
*/
export const setNoteOwner = async (noteId: string, owner: string): Promise<NotePermissions> => {
const response = await new PutApiRequestBuilder<NotePermissions, OwnerChangeDto>(
`notes/${noteId}/metadata/permissions/owner`
)
.withJsonBody({
owner
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Sets a permission for one user of a note.
* @param noteId The id of the note.
* @param username The username of the user to set the permission for.
* @param canEdit true if the user should be able to update the note, false otherwise.
*/
export const setUserPermission = async (
noteId: string,
username: string,
canEdit: boolean
): Promise<NotePermissions> => {
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
`notes/${noteId}/metadata/permissions/users/${username}`
)
.withJsonBody({
canEdit
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Sets a permission for one group of a note.
* @param noteId The id of the note.
* @param groupName The name of the group to set the permission for.
* @param canEdit true if the group should be able to update the note, false otherwise.
*/
export const setGroupPermission = async (
noteId: string,
groupName: string,
canEdit: boolean
): Promise<NotePermissions> => {
const response = await new PutApiRequestBuilder<NotePermissions, PermissionSetDto>(
`notes/${noteId}/metadata/permissions/groups/${groupName}`
)
.withJsonBody({
canEdit
})
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Removes the permissions of a note for a user.
* @param noteId The id of the note.
* @param username The name of the user to remove the permission of.
*/
export const removeUserPermission = async (noteId: string, username: string): Promise<NotePermissions> => {
const response = await new DeleteApiRequestBuilder<NotePermissions>(
`notes/${noteId}/metadata/permissions/users/${username}`
)
.withExpectedStatusCode(200)
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Removes the permissions of a note for a group.
* @param noteId The id of the note.
* @param groupName The name of the group to remove the permission of.
*/
export const removeGroupPermission = async (noteId: string, groupName: string): Promise<NotePermissions> => {
const response = await new DeleteApiRequestBuilder<NotePermissions>(
`notes/${noteId}/metadata/permissions/groups/${groupName}`
)
.withExpectedStatusCode(200)
.sendRequest()
return response.asParsedJsonObject()
}

View file

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface OwnerChangeDto {
owner: string
}
export interface PermissionSetDto {
canEdit: boolean
}

View file

@ -1,34 +1,39 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RevisionDetails, RevisionMetadata } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { Cache } from '../../components/common/cache/cache'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import type { Revision, RevisionListEntry } from './types'
const revisionCache = new Cache<string, Revision>(3600)
export const getRevision = async (noteId: string, timestamp: number): Promise<Revision> => {
const cacheKey = `${noteId}:${timestamp}`
if (revisionCache.has(cacheKey)) {
return revisionCache.get(cacheKey)
}
const response = await fetch(getApiUrl() + `notes/${noteId}/revisions/${timestamp}`, {
...defaultFetchConfig
})
expectResponseCode(response)
const revisionData = (await response.json()) as Revision
revisionCache.put(cacheKey, revisionData)
return revisionData
/**
* Retrieves a note revision while using a cache for often retrieved revisions.
* @param noteId The id of the note for which to fetch the revision.
* @param revisionId The id of the revision to fetch.
* @return The revision.
*/
export const getRevision = async (noteId: string, revisionId: number): Promise<RevisionDetails> => {
const response = await new GetApiRequestBuilder<RevisionDetails>(
`notes/${noteId}/revisions/${revisionId}`
).sendRequest()
return response.asParsedJsonObject()
}
export const getAllRevisions = async (noteId: string): Promise<RevisionListEntry[]> => {
// TODO Change 'revisions-list' to 'revisions' as soon as the backend is ready to serve some data!
const response = await fetch(getApiUrl() + `notes/${noteId}/revisions-list`, {
...defaultFetchConfig
})
expectResponseCode(response)
return (await response.json()) as Promise<RevisionListEntry[]>
/**
* Retrieves a list of all revisions stored for a given note.
* @param noteId The id of the note for which to look up the stored revisions.
* @return A list of revision ids.
*/
export const getAllRevisions = async (noteId: string): Promise<RevisionMetadata[]> => {
const response = await new GetApiRequestBuilder<RevisionMetadata[]>(`notes/${noteId}/revisions`).sendRequest()
return response.asParsedJsonObject()
}
/**
* Deletes all revisions for a note.
* @param noteIdOrAlias The id or alias of the note to delete all revisions for.
*/
export const deleteRevisionsForNote = async (noteIdOrAlias: string): Promise<void> => {
await new DeleteApiRequestBuilder(`notes/${noteIdOrAlias}/revisions`).sendRequest()
}

View file

@ -1,17 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Revision {
content: string
timestamp: number
authors: string[]
}
export interface RevisionListEntry {
timestamp: number
length: number
authors: string[]
}

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NoteEdit } from '../notes/types'
export interface RevisionDetails extends RevisionMetadata {
content: string
patch: string
edits: NoteEdit[]
}
export interface RevisionMetadata {
id: number
createdAt: string
length: number
authorUsernames: string[]
anonymousAuthorCount: number
}

View file

@ -1,37 +1,42 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { AccessToken, AccessTokenWithSecret, CreateAccessTokenDto } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import type { AccessToken, AccessTokenWithSecret } from './types'
/**
* Retrieves the access tokens for the current user.
* @return List of access token metadata.
*/
export const getAccessTokenList = async (): Promise<AccessToken[]> => {
const response = await fetch(`${getApiUrl()}tokens`, {
...defaultFetchConfig
})
expectResponseCode(response)
return (await response.json()) as AccessToken[]
const response = await new GetApiRequestBuilder<AccessToken[]>('tokens').sendRequest()
return response.asParsedJsonObject()
}
export const postNewAccessToken = async (label: string, expiryDate: string): Promise<AccessTokenWithSecret> => {
const response = await fetch(`${getApiUrl()}tokens`, {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
label: label,
validUntil: expiryDate
/**
* Creates a new access token for the current user.
* @param label The user-defined label for the new access token.
* @param validUntil The user-defined expiry date of the new access token in milliseconds of unix time.
* @return The new access token metadata along with its secret.
*/
export const postNewAccessToken = async (label: string, validUntil: number): Promise<AccessTokenWithSecret> => {
const response = await new PostApiRequestBuilder<AccessTokenWithSecret, CreateAccessTokenDto>('tokens')
.withJsonBody({
label,
validUntil
})
})
expectResponseCode(response)
return (await response.json()) as AccessTokenWithSecret
.sendRequest()
return response.asParsedJsonObject()
}
/**
* Removes an access token from the current user account.
* @param keyId The key id of the access token to delete.
*/
export const deleteAccessToken = async (keyId: string): Promise<void> => {
const response = await fetch(`${getApiUrl()}tokens/${keyId}`, {
...defaultFetchConfig,
method: 'DELETE'
})
expectResponseCode(response)
await new DeleteApiRequestBuilder('tokens/' + keyId).sendRequest()
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -9,9 +9,14 @@ export interface AccessToken {
validUntil: string
keyId: string
createdAt: string
lastUsed: string
lastUsedAt: string | null
}
export interface AccessTokenWithSecret extends AccessToken {
secret: string
}
export interface CreateAccessTokenDto {
label: string
validUntil: number
}

View file

@ -1,24 +1,18 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Cache } from '../../components/common/cache/cache'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import type { UserResponse } from './types'
import type { UserInfo } from './types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
const cache = new Cache<string, UserResponse>(600)
export const getUserById = async (userid: string): Promise<UserResponse> => {
if (cache.has(userid)) {
return cache.get(userid)
}
const response = await fetch(`${getApiUrl()}/users/${userid}`, {
...defaultFetchConfig
})
expectResponseCode(response)
const userData = (await response.json()) as UserResponse
cache.put(userid, userData)
return userData
/**
* Retrieves information about a specific user while using a cache to avoid many requests for the same username.
* @param username The username of interest.
* @return Metadata about the requested user.
*/
export const getUser = async (username: string): Promise<UserInfo> => {
const response = await new GetApiRequestBuilder<UserInfo>('users/' + username).sendRequest()
return response.asParsedJsonObject()
}

View file

@ -1,21 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { LoginProvider } from '../../redux/user/types'
export interface UserResponse {
id: string
name: string
photo: string
provider: LoginProvider
}
export interface UserInfoDto {
username: string
displayName: string
photo: string
email: string
}

11
src/api/users/types.ts Normal file
View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface UserInfo {
username: string
displayName: string
photo: string
}

View file

@ -1,29 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getGlobalState } from '../redux'
export const defaultFetchConfig: Partial<RequestInit> = {
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
method: 'GET'
}
export const getApiUrl = (): string => {
return getGlobalState().apiUrl.apiUrl
}
export const expectResponseCode = (response: Response, code = 200): void => {
if (response.status !== code) {
throw new Error(`response code is not ${code}`)
}
}