From 933455e308c9826f10fa7126b6cb69a4aee0858d Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Wed, 15 Mar 2023 16:06:25 +0200 Subject: [PATCH] feat: use api instead of localStorage (#3305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR replaces localStorage with api calls for getting/setting project scoped stickiness ## About the changes Closes # ### Important files ## Discussion points --------- Signed-off-by: andreas-unleash --- .../EnvironmentVariantsModal.tsx | 4 +- .../FlexibleStrategy/FlexibleStrategy.tsx | 11 +- .../Project/CreateProject/CreateProject.tsx | 12 +- .../Project/EditProject/EditProject.tsx | 11 +- .../project/Project/hooks/useProjectForm.ts | 4 +- .../actions/useProjectApi/useProjectApi.ts | 14 ++ .../src/hooks/useDefaultProjectSettings.ts | 62 +++++++ .../src/hooks/useDefaultProjectStickiness.ts | 35 ---- src/lib/openapi/index.ts | 4 +- src/lib/openapi/spec/index.ts | 2 +- .../openapi/spec/project-settings-schema.ts | 23 +++ src/lib/openapi/spec/stickiness-schema.ts | 17 -- src/lib/routes/admin-api/project/index.ts | 73 ++++---- .../api/admin/project/projects.e2e.test.ts | 6 +- .../__snapshots__/openapi.e2e.test.ts.snap | 164 ++++++++++-------- 15 files changed, 253 insertions(+), 189 deletions(-) create mode 100644 frontend/src/hooks/useDefaultProjectSettings.ts delete mode 100644 frontend/src/hooks/useDefaultProjectStickiness.ts create mode 100644 src/lib/openapi/spec/project-settings-schema.ts delete mode 100644 src/lib/openapi/spec/stickiness-schema.ts diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx index 7bbaaed87c..75ef7ef237 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx @@ -19,7 +19,7 @@ import { v4 as uuidv4 } from 'uuid'; import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; import { updateWeightEdit } from 'component/common/util'; import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; -import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness'; +import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; const StyledFormSubtitle = styled('div')(({ theme }) => ({ display: 'flex', @@ -145,7 +145,7 @@ export const EnvironmentVariantsModal = ({ const { uiConfig } = useUiConfig(); const { context } = useUnleashContext(); - const { defaultStickiness } = useDefaultProjectStickiness(projectId); + const { defaultStickiness } = useDefaultProjectSettings(projectId); const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); const { data } = usePendingChangeRequests(projectId); diff --git a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx index e6de3eb3b5..bc1797739e 100644 --- a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx +++ b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx @@ -12,9 +12,9 @@ import { parseParameterString, } from 'utils/parseParameter'; import { StickinessSelect } from './StickinessSelect/StickinessSelect'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; -import { useDefaultProjectStickiness } from '../../../../hooks/useDefaultProjectStickiness'; +import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; +import Loader from '../../../common/Loader/Loader'; interface IFlexibleStrategyProps { parameters: IFeatureStrategyParameters; @@ -29,8 +29,7 @@ const FlexibleStrategy = ({ editable = true, }: IFlexibleStrategyProps) => { const projectId = useOptionalPathParam('projectId'); - const { defaultStickiness } = useDefaultProjectStickiness(projectId); - + const { defaultStickiness, loading } = useDefaultProjectSettings(projectId); const onUpdate = (field: string) => (newValue: string) => { updateParameter(field, newValue); }; @@ -58,6 +57,10 @@ const FlexibleStrategy = ({ onUpdate('stickiness')(stickiness); } + if (loading) { + return ; + } + return (
{ const { setToastData, setToastApiError } = useToast(); @@ -33,10 +32,8 @@ const CreateProject = () => { errors, } = useProjectForm(); - const { createProject, loading } = useProjectApi(); - - const { setDefaultProjectStickiness } = - useDefaultProjectStickiness(projectId); + const { createProject, setDefaultProjectStickiness, loading } = + useProjectApi(); const handleSubmit = async (e: Event) => { e.preventDefault(); @@ -48,7 +45,10 @@ const CreateProject = () => { const payload = getProjectPayload(); try { await createProject(payload); - setDefaultProjectStickiness(payload.projectStickiness); + setDefaultProjectStickiness( + projectId, + payload.projectStickiness + ); refetchUser(); navigate(`/projects/${projectId}`); setToastData({ diff --git a/frontend/src/component/project/Project/EditProject/EditProject.tsx b/frontend/src/component/project/Project/EditProject/EditProject.tsx index becf94a9ef..7326707a87 100644 --- a/frontend/src/component/project/Project/EditProject/EditProject.tsx +++ b/frontend/src/component/project/Project/EditProject/EditProject.tsx @@ -14,7 +14,7 @@ import { useContext } from 'react'; import AccessContext from 'contexts/AccessContext'; import { Alert } from '@mui/material'; import { GO_BACK } from 'constants/navigate'; -import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness'; +import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; const EditProject = () => { const { uiConfig } = useUiConfig(); @@ -22,8 +22,8 @@ const EditProject = () => { const { hasAccess } = useContext(AccessContext); const id = useRequiredPathParam('projectId'); const { project } = useProject(id); - const { defaultStickiness, setDefaultProjectStickiness } = - useDefaultProjectStickiness(id); + const { setDefaultProjectStickiness } = useProjectApi(); + const { defaultStickiness } = useDefaultProjectSettings(id); const navigate = useNavigate(); const { @@ -68,7 +68,10 @@ const EditProject = () => { if (validName) { try { await editProject(id, payload); - setDefaultProjectStickiness(payload.projectStickiness); + setDefaultProjectStickiness( + projectId, + payload.projectStickiness + ); refetch(); navigate(`/projects/${id}`); setToastData({ diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index 589b126441..6a5d7262aa 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import { formatUnknownError } from 'utils/formatUnknownError'; -import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness'; +import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; const useProjectForm = ( initialProjectId = '', @@ -10,7 +10,7 @@ const useProjectForm = ( initialProjectStickiness = 'default' ) => { const [projectId, setProjectId] = useState(initialProjectId); - const { defaultStickiness } = useDefaultProjectStickiness(projectId); + const { defaultStickiness } = useDefaultProjectSettings(projectId); const [projectName, setProjectName] = useState(initialProjectName); const [projectDesc, setProjectDesc] = useState(initialProjectDesc); diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index 2f64d621ea..58af016ed2 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -202,6 +202,19 @@ const useProjectApi = () => { return makeRequest(req.caller, req.id); }; + const setDefaultProjectStickiness = ( + projectId: string, + stickiness: string + ) => { + const path = `api/admin/projects/${projectId}/stickiness`; + const req = createRequest(path, { + method: 'POST', + body: JSON.stringify({ stickiness }), + }); + + return makeRequest(req.caller, req.id); + }; + return { createProject, validateId, @@ -217,6 +230,7 @@ const useProjectApi = () => { errors, loading, searchProjectUser, + setDefaultProjectStickiness, }; }; diff --git a/frontend/src/hooks/useDefaultProjectSettings.ts b/frontend/src/hooks/useDefaultProjectSettings.ts new file mode 100644 index 0000000000..bd19f0e609 --- /dev/null +++ b/frontend/src/hooks/useDefaultProjectSettings.ts @@ -0,0 +1,62 @@ +import useUiConfig from './api/getters/useUiConfig/useUiConfig'; +import useSWR, { SWRConfiguration } from 'swr'; +import { useCallback } from 'react'; +import handleErrorResponses from './api/getters/httpErrorResponseHandler'; + +export interface IStickinessResponse { + status: number; + + body?: { + defaultStickiness: string; + mode?: string; + }; +} +const DEFAULT_STICKINESS = 'default'; +export const useDefaultProjectSettings = ( + projectId?: string, + options?: SWRConfiguration +) => { + const { uiConfig } = useUiConfig(); + + const PATH = `/api/admin/projects/${projectId}/settings`; + const { projectScopedStickiness } = uiConfig.flags; + + const { data, error, mutate } = useSWR( + ['useDefaultProjectSettings', PATH], + () => fetcher(PATH), + options + ); + + const defaultStickiness = + Boolean(projectScopedStickiness) && data?.body != null && projectId + ? data.body.defaultStickiness + : DEFAULT_STICKINESS; + + const refetch = useCallback(() => { + mutate().catch(console.warn); + }, [mutate]); + return { + defaultStickiness, + refetch, + loading: !error && !data, + status: data?.status, + error, + }; +}; + +export const fetcher = async (path: string): Promise => { + const res = await fetch(path); + + if (res.status === 404) { + return { status: 404 }; + } + + if (!res.ok) { + await handleErrorResponses('Project stickiness data')(res); + } + + return { + status: res.status, + body: await res.json(), + }; +}; diff --git a/frontend/src/hooks/useDefaultProjectStickiness.ts b/frontend/src/hooks/useDefaultProjectStickiness.ts deleted file mode 100644 index 4387a62040..0000000000 --- a/frontend/src/hooks/useDefaultProjectStickiness.ts +++ /dev/null @@ -1,35 +0,0 @@ -import useUiConfig from './api/getters/useUiConfig/useUiConfig'; -import { usePlausibleTracker } from './usePlausibleTracker'; - -const DEFAULT_STICKINESS = 'default'; -export const useDefaultProjectStickiness = (projectId?: string) => { - const { trackEvent } = usePlausibleTracker(); - const { uiConfig } = useUiConfig(); - - const key = `defaultStickiness.${projectId}`; - const { projectScopedStickiness } = uiConfig.flags; - const projectStickiness = localStorage.getItem(key); - - const defaultStickiness = - Boolean(projectScopedStickiness) && - projectStickiness != null && - projectId - ? projectStickiness - : DEFAULT_STICKINESS; - - const setDefaultProjectStickiness = (stickiness: string) => { - if ( - Boolean(projectScopedStickiness) && - projectId && - stickiness !== '' - ) { - localStorage.setItem(key, stickiness); - trackEvent('project_stickiness_set'); - } - }; - - return { - defaultStickiness, - setDefaultProjectStickiness, - }; -}; diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index e4d35eda85..8c30752558 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -133,7 +133,7 @@ import { importTogglesSchema, importTogglesValidateSchema, importTogglesValidateItemSchema, - stickinessSchema, + projectSettingsSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -257,7 +257,7 @@ export const schemas = { stateSchema, strategiesSchema, strategySchema, - stickinessSchema, + projectSettingsSchema, tagsBulkAddSchema, tagSchema, tagsSchema, diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 401c8d2c9c..69c583db10 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -129,7 +129,7 @@ export * from './project-overview-schema'; export * from './import-toggles-validate-item-schema'; export * from './import-toggles-validate-schema'; export * from './import-toggles-schema'; -export * from './stickiness-schema'; +export * from './project-settings-schema'; export * from './tags-bulk-add-schema'; export * from './upsert-segment-schema'; export * from './batch-features-schema'; diff --git a/src/lib/openapi/spec/project-settings-schema.ts b/src/lib/openapi/spec/project-settings-schema.ts new file mode 100644 index 0000000000..243a5a283b --- /dev/null +++ b/src/lib/openapi/spec/project-settings-schema.ts @@ -0,0 +1,23 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const projectSettingsSchema = { + $id: '#/components/schemas/projectSettingsSchema', + type: 'object', + additionalProperties: false, + required: ['defaultStickiness'], + properties: { + defaultStickiness: { + type: 'string', + example: 'userId', + nullable: true, + }, + mode: { + type: 'string', + enum: ['open', 'protected', 'private'], + nullable: true, + }, + }, + components: {}, +} as const; + +export type ProjectSettingsSchema = FromSchema; diff --git a/src/lib/openapi/spec/stickiness-schema.ts b/src/lib/openapi/spec/stickiness-schema.ts deleted file mode 100644 index ecd6d4697a..0000000000 --- a/src/lib/openapi/spec/stickiness-schema.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { FromSchema } from 'json-schema-to-ts'; - -export const stickinessSchema = { - $id: '#/components/schemas/stickinessSchema', - type: 'object', - additionalProperties: false, - required: ['stickiness'], - properties: { - stickiness: { - type: 'string', - example: 'userId', - }, - }, - components: {}, -} as const; - -export type StickinessSchema = FromSchema; diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts index 19f23ba26e..b31e347666 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/index.ts @@ -1,31 +1,32 @@ import { Response } from 'express'; import Controller from '../../controller'; -import { IUnleashConfig } from '../../../types/option'; -import { IUnleashServices } from '../../../types/services'; +import { + IArchivedQuery, + IProjectParam, + IUnleashConfig, + IUnleashServices, + NONE, + serializeDates, + UPDATE_PROJECT, +} from '../../../types'; import ProjectFeaturesController from './project-features'; import EnvironmentsController from './environments'; import ProjectHealthReport from './health-report'; import ProjectService from '../../../services/project-service'; import VariantsController from './variants'; -import { NONE, UPDATE_PROJECT } from '../../../types/permissions'; -import { - projectsSchema, - ProjectsSchema, -} from '../../../openapi/spec/projects-schema'; -import { OpenApiService } from '../../../services/openapi-service'; -import { serializeDates } from '../../../types/serialize-dates'; -import { createResponseSchema } from '../../../openapi/util/create-response-schema'; -import { IAuthRequest } from '../../unleash-types'; import { + createResponseSchema, emptyResponse, ProjectOverviewSchema, projectOverviewSchema, - stickinessSchema, - StickinessSchema, -} from '../../../../lib/openapi'; -import { IArchivedQuery, IProjectParam } from '../../../types/model'; + ProjectSettingsSchema, + projectSettingsSchema, + projectsSchema, + ProjectsSchema, +} from '../../../openapi'; +import { OpenApiService, SettingService } from '../../../services'; +import { IAuthRequest } from '../../unleash-types'; import { ProjectApiTokenController } from './api-token'; -import { SettingService } from '../../../services'; import ProjectArchiveController from './project-archive'; const STICKINESS_KEY = 'stickiness'; @@ -78,15 +79,15 @@ export default class ProjectApi extends Controller { this.route({ method: 'get', - path: '/:projectId/stickiness', - handler: this.getProjectDefaultStickiness, + path: '/:projectId/settings', + handler: this.getProjectSettings, permission: NONE, middleware: [ services.openApiService.validPath({ tags: ['Projects'], - operationId: 'getProjectDefaultStickiness', + operationId: 'getProjectSettings', responses: { - 200: createResponseSchema('stickinessSchema'), + 200: createResponseSchema('projectSettingsSchema'), 404: emptyResponse, }, }), @@ -95,15 +96,15 @@ export default class ProjectApi extends Controller { this.route({ method: 'post', - path: '/:projectId/stickiness', - handler: this.setProjectDefaultStickiness, + path: '/:projectId/settings', + handler: this.setProjectSettings, permission: UPDATE_PROJECT, middleware: [ services.openApiService.validPath({ tags: ['Projects'], - operationId: 'setProjectDefaultStickiness', + operationId: 'setProjectSettings', responses: { - 200: createResponseSchema('stickinessSchema'), + 200: createResponseSchema('projectSettingsSchema'), 404: emptyResponse, }, }), @@ -158,9 +159,9 @@ export default class ProjectApi extends Controller { ); } - async getProjectDefaultStickiness( + async getProjectSettings( req: IAuthRequest, - res: Response, + res: Response, ): Promise { if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) { res.status(404); @@ -176,33 +177,33 @@ export default class ProjectApi extends Controller { this.openApiService.respondWithValidation( 200, res, - stickinessSchema.$id, - { stickiness: stickinessSettings[projectId] }, + projectSettingsSchema.$id, + { defaultStickiness: stickinessSettings[projectId] }, ); } - async setProjectDefaultStickiness( + async setProjectSettings( req: IAuthRequest< IProjectParam, - StickinessSchema, - StickinessSchema, + ProjectSettingsSchema, + ProjectSettingsSchema, unknown >, - res: Response, + res: Response, ): Promise { if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) { res.status(404); return Promise.resolve(); } const { projectId } = req.params; - const { stickiness } = req.body; + const { defaultStickiness } = req.body; const stickinessSettings = await this.settingService.get<{}>( STICKINESS_KEY, { [projectId]: DEFAULT_STICKINESS, }, ); - stickinessSettings[projectId] = stickiness; + stickinessSettings[projectId] = defaultStickiness; await this.settingService.insert( STICKINESS_KEY, stickinessSettings, @@ -212,8 +213,8 @@ export default class ProjectApi extends Controller { this.openApiService.respondWithValidation( 200, res, - stickinessSchema.$id, - { stickiness: stickiness }, + projectSettingsSchema.$id, + { defaultStickiness }, ); } } diff --git a/src/test/e2e/api/admin/project/projects.e2e.test.ts b/src/test/e2e/api/admin/project/projects.e2e.test.ts index c50fc540f4..3d17505065 100644 --- a/src/test/e2e/api/admin/project/projects.e2e.test.ts +++ b/src/test/e2e/api/admin/project/projects.e2e.test.ts @@ -46,15 +46,15 @@ test('Should store and retrieve default project stickiness', async () => { }, }, }); - const reqBody = { stickiness: 'userId' }; + const reqBody = { defaultStickiness: 'userId' }; await appWithDefaultStickiness.request - .post('/api/admin/projects/default/stickiness') + .post('/api/admin/projects/default/settings') .send(reqBody) .expect(200); const { body } = await appWithDefaultStickiness.request - .get('/api/admin/projects/default/stickiness') + .get('/api/admin/projects/default/settings') .expect(200) .expect('Content-Type', /json/); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 35baf38bf2..aef007d8c9 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -2863,6 +2863,29 @@ exports[`should serve the OpenAPI spec 1`] = ` ], "type": "object", }, + "projectSettingsSchema": { + "additionalProperties": false, + "properties": { + "defaultStickiness": { + "example": "userId", + "nullable": true, + "type": "string", + }, + "mode": { + "enum": [ + "open", + "protected", + "private", + ], + "nullable": true, + "type": "string", + }, + }, + "required": [ + "defaultStickiness", + ], + "type": "object", + }, "projectStatsSchema": { "additionalProperties": false, "description": "Statistics for a project, including the average time to production, number of features created, the project activity and more. @@ -3546,19 +3569,6 @@ Stats are divided into current and previous **windows**. ], "type": "object", }, - "stickinessSchema": { - "additionalProperties": false, - "properties": { - "stickiness": { - "example": "userId", - "type": "string", - }, - }, - "required": [ - "stickiness", - ], - "type": "object", - }, "strategiesSchema": { "additionalProperties": false, "properties": { @@ -7484,6 +7494,70 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/projects/{projectId}/settings": { + "get": { + "operationId": "getProjectSettings", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/projectSettingsSchema", + }, + }, + }, + "description": "projectSettingsSchema", + }, + "404": { + "description": "This response has no body.", + }, + }, + "tags": [ + "Projects", + ], + }, + "post": { + "operationId": "setProjectSettings", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/projectSettingsSchema", + }, + }, + }, + "description": "projectSettingsSchema", + }, + "404": { + "description": "This response has no body.", + }, + }, + "tags": [ + "Projects", + ], + }, + }, "/api/admin/projects/{projectId}/stale": { "post": { "description": "This endpoint stales the specified features.", @@ -7520,70 +7594,6 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, - "/api/admin/projects/{projectId}/stickiness": { - "get": { - "operationId": "getProjectDefaultStickiness", - "parameters": [ - { - "in": "path", - "name": "projectId", - "required": true, - "schema": { - "type": "string", - }, - }, - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/stickinessSchema", - }, - }, - }, - "description": "stickinessSchema", - }, - "404": { - "description": "This response has no body.", - }, - }, - "tags": [ - "Projects", - ], - }, - "post": { - "operationId": "setProjectDefaultStickiness", - "parameters": [ - { - "in": "path", - "name": "projectId", - "required": true, - "schema": { - "type": "string", - }, - }, - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/stickinessSchema", - }, - }, - }, - "description": "stickinessSchema", - }, - "404": { - "description": "This response has no body.", - }, - }, - "tags": [ - "Projects", - ], - }, - }, "/api/admin/splash/{id}": { "post": { "operationId": "updateSplashSettings",