diff --git a/configs/config.hcl b/configs/config.hcl index 1c343814e..c28515ba0 100644 --- a/configs/config.hcl +++ b/configs/config.hcl @@ -90,6 +90,14 @@ email { from_address = "hermes@yourorganization.com" } +// FeatureFlags contain available feature flags. +feature_flags { + // api_v2 enables v2 of the API. + flag "api_v2" { + enabled = false + } +} + // google_workspace configures Hermes to work with Google Workspace. google_workspace { // create_doc_shortcuts enables creating a shortcut in the shortcuts_folder diff --git a/web/app/authenticators/torii.ts b/web/app/authenticators/torii.ts index 1517e1790..194cd4c4b 100644 --- a/web/app/authenticators/torii.ts +++ b/web/app/authenticators/torii.ts @@ -1,9 +1,11 @@ // @ts-ignore -- TODO: Add Types import Torii from "ember-simple-auth/authenticators/torii"; import { inject as service } from "@ember/service"; +import ConfigService from "hermes/services/config"; import FetchService from "hermes/services/fetch"; export default class ToriiAuthenticator extends Torii { + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; // Appears unused, but necessary for the session service @@ -16,7 +18,7 @@ export default class ToriiAuthenticator extends Torii { * in the session being invalidated or remaining unauthenticated. */ return this.fetchSvc - .fetch("/api/v1/me", { + .fetch(`/api/${this.configSvc.config.api_version}/me`, { method: "HEAD", headers: { "Hermes-Google-Access-Token": data.access_token, diff --git a/web/app/components/document/index.ts b/web/app/components/document/index.ts index 189e2067d..53cb616d7 100644 --- a/web/app/components/document/index.ts +++ b/web/app/components/document/index.ts @@ -3,6 +3,7 @@ import { inject as service } from "@ember/service"; import { dropTask } from "ember-concurrency"; import { HermesDocument } from "hermes/types/document"; import { AuthenticatedUser } from "hermes/services/authenticated-user"; +import ConfigService from "hermes/services/config"; import FetchService from "hermes/services/fetch"; import RouterService from "@ember/routing/router-service"; import FlashMessageService from "ember-cli-flash/services/flash-messages"; @@ -21,6 +22,7 @@ interface DocumentIndexComponentSignature { export default class DocumentIndexComponent extends Component { @service declare authenticatedUser: AuthenticatedUser; + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; @service declare router: RouterService; @service declare flashMessages: FlashMessageService; @@ -35,10 +37,13 @@ export default class DocumentIndexComponent extends Component { try { - let fetchResponse = await this.fetchSvc.fetch("/api/v1/drafts/" + docID, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - }); + let fetchResponse = await this.fetchSvc.fetch( + `/api/${this.configSvc.config.api_version}/drafts/` + docID, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }, + ); if (!fetchResponse?.ok) { this.showError(fetchResponse?.statusText); diff --git a/web/app/components/document/sidebar.ts b/web/app/components/document/sidebar.ts index 18e603303..5100b682c 100644 --- a/web/app/components/document/sidebar.ts +++ b/web/app/components/document/sidebar.ts @@ -544,13 +544,16 @@ export default class DocumentSidebarComponent extends Component { try { + let apiVersion = "v1"; + if (this.configSvc.config.feature_flags["api_v2"]) { + apiVersion = "v2"; + } + const response = await this.fetchSvc - .fetch(`/api/v1/drafts/${this.docID}/shareable`) + .fetch(`/api/${apiVersion}/drafts/${this.docID}/shareable`) .then((response) => response?.json()); if (response?.isShareable) { this._docIsShareable = true; @@ -799,10 +816,13 @@ export default class DocumentSidebarComponent extends Component { try { - await this.fetchSvc.fetch(`/api/v1/approvals/${this.docID}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); + await this.fetchSvc.fetch( + `/api/${this.configSvc.config.api_version}/approvals/${this.docID}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); this.showFlashSuccess("Done!", "Document approved"); } catch (error: unknown) { this.maybeShowFlashError(error as Error, "Unable to approve"); @@ -814,10 +834,13 @@ export default class DocumentSidebarComponent extends Component { try { - await this.fetchSvc.fetch(`/api/v1/approvals/${this.docID}`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - }); + await this.fetchSvc.fetch( + `/api/${this.configSvc.config.api_version}/approvals/${this.docID}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }, + ); // Add a notification for the user let msg = "Requested changes for document"; // FRDs are a special case that can be approved or not approved. diff --git a/web/app/components/document/sidebar/related-resources.ts b/web/app/components/document/sidebar/related-resources.ts index b9324f55d..06f0d1fd4 100644 --- a/web/app/components/document/sidebar/related-resources.ts +++ b/web/app/components/document/sidebar/related-resources.ts @@ -218,9 +218,9 @@ export default class DocumentSidebarRelatedResourcesComponent extends Component< try { const resources = await this.fetchSvc .fetch( - `/api/v1/${this.args.documentIsDraft ? "drafts" : "documents"}/${ - this.args.objectID - }/related-resources`, + `/api/${this.configSvc.config.api_version}/${ + this.args.documentIsDraft ? "drafts" : "documents" + }/${this.args.objectID}/related-resources`, ) .then((response) => response?.json()); @@ -315,9 +315,9 @@ export default class DocumentSidebarRelatedResourcesComponent extends Component< try { await this.fetchSvc.fetch( - `/api/v1/${this.args.documentIsDraft ? "drafts" : "documents"}/${ - this.args.objectID - }/related-resources`, + `/api/${this.configSvc.config.api_version}/${ + this.args.documentIsDraft ? "drafts" : "documents" + }/${this.args.objectID}/related-resources`, { method: "PUT", body: JSON.stringify(this.formattedRelatedResources), diff --git a/web/app/components/inputs/people-select.ts b/web/app/components/inputs/people-select.ts index 2c54abfa0..bb8d0604b 100644 --- a/web/app/components/inputs/people-select.ts +++ b/web/app/components/inputs/people-select.ts @@ -3,6 +3,7 @@ import { tracked } from "@glimmer/tracking"; import { inject as service } from "@ember/service"; import { restartableTask, timeout } from "ember-concurrency"; import { action } from "@ember/object"; +import ConfigService from "hermes/services/config"; import FetchService from "hermes/services/fetch"; import { HermesUser } from "hermes/types/document"; import Ember from "ember"; @@ -27,6 +28,7 @@ const MAX_RETRIES = 3; const INITIAL_RETRY_DELAY = Ember.testing ? 0 : 500; export default class InputsPeopleSelectComponent extends Component { + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; /** @@ -65,13 +67,16 @@ export default class InputsPeopleSelectComponent extends Component { + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; @service declare authenticatedUser: AuthenticatedUserService; @service declare flashMessages: FlashService; @@ -195,7 +197,7 @@ export default class NewDocFormComponent extends Component response?.json()) .then((json) => { this.config.setConfig(json); diff --git a/web/app/routes/authenticated/document.ts b/web/app/routes/authenticated/document.ts index d63c09a00..e9157ebde 100644 --- a/web/app/routes/authenticated/document.ts +++ b/web/app/routes/authenticated/document.ts @@ -2,6 +2,7 @@ import Route from "@ember/routing/route"; import { inject as service } from "@ember/service"; import htmlElement from "hermes/utils/html-element"; import { schedule } from "@ember/runloop"; +import ConfigService from "hermes/services/config"; import FetchService from "hermes/services/fetch"; import FlashMessageService from "ember-cli-flash/services/flash-messages"; import RouterService from "@ember/routing/router-service"; @@ -31,6 +32,7 @@ interface DocumentRouteModel { } export default class AuthenticatedDocumentRoute extends Route { + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; @service("recently-viewed-docs") declare recentDocs: RecentlyViewedDocsService; @@ -60,7 +62,7 @@ export default class AuthenticatedDocumentRoute extends Route { async docType(doc: HermesDocument) { const docTypes = (await this.fetchSvc - .fetch("/api/v1/document-types") + .fetch(`/api/${this.configSvc.config.api_version}/document-types`) .then((r) => r?.json())) as HermesDocumentType[]; assert("docTypes must exist", docTypes); @@ -82,14 +84,18 @@ export default class AuthenticatedDocumentRoute extends Route { if (params.draft) { try { doc = await this.fetchSvc - .fetch("/api/v1/drafts/" + params.document_id, { - method: "GET", - headers: { - // We set this header to differentiate between document views and - // requests to only retrieve document metadata. - "Add-To-Recently-Viewed": "true", + .fetch( + `/api/${this.configSvc.config.api_version}/drafts/` + + params.document_id, + { + method: "GET", + headers: { + // We set this header to differentiate between document views and + // requests to only retrieve document metadata. + "Add-To-Recently-Viewed": "true", + }, }, - }) + ) .then((r) => r?.json()); (doc as HermesDocument).isDraft = params.draft; draftFetched = true; @@ -108,14 +114,18 @@ export default class AuthenticatedDocumentRoute extends Route { if (!draftFetched) { try { doc = await this.fetchSvc - .fetch("/api/v1/documents/" + params.document_id, { - method: "GET", - headers: { - // We set this header to differentiate between document views and - // requests to only retrieve document metadata. - "Add-To-Recently-Viewed": "true", + .fetch( + `/api/${this.configSvc.config.api_version}/documents/` + + params.document_id, + { + method: "GET", + headers: { + // We set this header to differentiate between document views and + // requests to only retrieve document metadata. + "Add-To-Recently-Viewed": "true", + }, }, - }) + ) .then((r) => r?.json()); (doc as HermesDocument).isDraft = false; @@ -134,7 +144,11 @@ export default class AuthenticatedDocumentRoute extends Route { // Preload avatars for all approvers in the Algolia index. if (typedDoc.contributors?.length) { const contributors = await this.fetchSvc - .fetch(`/api/v1/people?emails=${typedDoc.contributors.join(",")}`) + .fetch( + `/api/${ + this.configSvc.config.api_version + }/people?emails=${typedDoc.contributors.join(",")}`, + ) .then((r) => r?.json()); if (contributors) { @@ -145,7 +159,11 @@ export default class AuthenticatedDocumentRoute extends Route { } if (typedDoc.approvers?.length) { const approvers = await this.fetchSvc - .fetch(`/api/v1/people?emails=${typedDoc.approvers.join(",")}`) + .fetch( + `/api/${ + this.configSvc.config.api_version + }/people?emails=${typedDoc.approvers.join(",")}`, + ) .then((r) => r?.json()); if (approvers) { @@ -171,14 +189,17 @@ export default class AuthenticatedDocumentRoute extends Route { /** * Record the document view with the analytics backend. */ - void this.fetchSvc.fetch("/api/v1/web/analytics", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - document_id: model.doc.objectID, - product_name: model.doc.product, - }), - }); + void this.fetchSvc.fetch( + `/api/${this.configSvc.config.api_version}/web/analytics`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + document_id: model.doc.objectID, + product_name: model.doc.product, + }), + }, + ); /** * Once the model has resolved, check if the document is loading from diff --git a/web/app/routes/authenticated/drafts.ts b/web/app/routes/authenticated/drafts.ts index 352276e97..3ed6e74fa 100644 --- a/web/app/routes/authenticated/drafts.ts +++ b/web/app/routes/authenticated/drafts.ts @@ -7,6 +7,7 @@ import AlgoliaService, { HITS_PER_PAGE, MAX_VALUES_PER_FACET, } from "hermes/services/algolia"; +import ConfigService from "hermes/services/config"; import { DocumentsRouteParams } from "hermes/types/document-routes"; import { FacetRecords } from "hermes/types/facets"; import AuthenticatedUserService from "hermes/services/authenticated-user"; @@ -24,6 +25,7 @@ interface DraftResponseJSON { } export default class AuthenticatedDraftsRoute extends Route { + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; @service declare algolia: AlgoliaService; @service declare activeFilters: ActiveFiltersService; @@ -55,7 +57,7 @@ export default class AuthenticatedDraftsRoute extends Route { */ private createDraftURLSearchParams( params: AlgoliaSearchParams, - ownerFacetOnly: boolean + ownerFacetOnly: boolean, ): URLSearchParams { /** * In the case of facets, we want to filter by just the owner facet. @@ -76,7 +78,7 @@ export default class AuthenticatedDraftsRoute extends Route { ownerEmail: this.authenticatedUser.info.email, }) .map(([key, val]) => `${key}=${val}`) - .join("&") + .join("&"), ); } @@ -86,20 +88,20 @@ export default class AuthenticatedDraftsRoute extends Route { private getDraftResults = task( async ( params: AlgoliaSearchParams, - ownerFacetOnly = false + ownerFacetOnly = false, ): Promise => { try { let response = await this.fetchSvc .fetch( - "/api/v1/drafts?" + - this.createDraftURLSearchParams(params, ownerFacetOnly) + `/api/${this.configSvc.config.api_version}/drafts?` + + this.createDraftURLSearchParams(params, ownerFacetOnly), ) .then((response) => response?.json()); return response; } catch (e: unknown) { console.error(e); } - } + }, ); /** * Gets facets for the drafts page. Scoped to the current user. @@ -116,7 +118,7 @@ export default class AuthenticatedDraftsRoute extends Route { * Map the facets to a new object with additional nested properties */ let facets: FacetRecords = this.algolia.mapStatefulFacetKeys( - algoliaFacets.facets + algoliaFacets.facets, ); Object.entries(facets).forEach(([name, facet]) => { @@ -130,7 +132,7 @@ export default class AuthenticatedDraftsRoute extends Route { } catch (e) { console.error(e); } - } + }, ); async model(params: DocumentsRouteParams) { diff --git a/web/app/routes/authenticated/new/index.ts b/web/app/routes/authenticated/new/index.ts index 46f2f6548..36812e4fb 100644 --- a/web/app/routes/authenticated/new/index.ts +++ b/web/app/routes/authenticated/new/index.ts @@ -1,14 +1,16 @@ import Route from "@ember/routing/route"; import { inject as service } from "@ember/service"; +import ConfigService from "hermes/services/config"; import FetchService from "hermes/services/fetch"; import { HermesDocumentType } from "hermes/types/document-type"; export default class AuthenticatedNewIndexRoute extends Route { + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; async model() { return (await this.fetchSvc - .fetch("/api/v1/document-types") + .fetch(`/api/${this.configSvc.config.api_version}/document-types`) .then((r) => r?.json())) as HermesDocumentType[]; } } diff --git a/web/app/services/authenticated-user.ts b/web/app/services/authenticated-user.ts index 2ea2cdbec..19e13c36e 100644 --- a/web/app/services/authenticated-user.ts +++ b/web/app/services/authenticated-user.ts @@ -4,6 +4,7 @@ import { inject as service } from "@ember/service"; import Store from "@ember-data/store"; import { assert } from "@ember/debug"; import { task } from "ember-concurrency"; +import ConfigService from "hermes/services/config"; import FetchService from "hermes/services/fetch"; import SessionService from "./session"; @@ -26,6 +27,7 @@ enum SubscriptionType { } export default class AuthenticatedUserService extends Service { + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; @service declare session: SessionService; @service declare store: Store; @@ -46,7 +48,7 @@ export default class AuthenticatedUserService extends Service { private get subscriptionsPostBody(): string { assert("subscriptions must be defined", this.subscriptions); let subscriptions = this.subscriptions.map( - (subscription: Subscription) => subscription.productArea + (subscription: Subscription) => subscription.productArea, ); return JSON.stringify({ subscriptions }); } @@ -69,7 +71,7 @@ export default class AuthenticatedUserService extends Service { loadInfo = task(async () => { try { this._info = await this.fetchSvc - .fetch("/api/v1/me") + .fetch(`/api/${this.configSvc.config.api_version}/me`) .then((response) => response?.json()); } catch (e: unknown) { console.error("Error getting user information: ", e); @@ -84,7 +86,7 @@ export default class AuthenticatedUserService extends Service { fetchSubscriptions = task(async () => { try { let subscriptions = await this.fetchSvc - .fetch("/api/v1/me/subscriptions", { + .fetch(`/api/${this.configSvc.config.api_version}/me/subscriptions`, { method: "GET", }) .then((response) => response?.json()); @@ -113,11 +115,11 @@ export default class AuthenticatedUserService extends Service { addSubscription = task( async ( productArea: string, - subscriptionType = SubscriptionType.Instant + subscriptionType = SubscriptionType.Instant, ) => { assert( "removeSubscription expects a valid subscriptions array", - this.subscriptions + this.subscriptions, ); let cached = this.subscriptions; @@ -128,17 +130,20 @@ export default class AuthenticatedUserService extends Service { }); try { - await this.fetchSvc.fetch(`/api/v1/me/subscriptions`, { - method: "POST", - headers: this.subscriptionsPostHeaders, - body: this.subscriptionsPostBody, - }); + await this.fetchSvc.fetch( + `/api/${this.configSvc.config.api_version}/me/subscriptions`, + { + method: "POST", + headers: this.subscriptionsPostHeaders, + body: this.subscriptionsPostBody, + }, + ); } catch (e: unknown) { console.error("Error updating subscriptions: ", e); this.subscriptions = cached; throw e; } - } + }, ); /** @@ -147,36 +152,39 @@ export default class AuthenticatedUserService extends Service { removeSubscription = task( async ( productArea: string, - subscriptionType = SubscriptionType.Instant + subscriptionType = SubscriptionType.Instant, ) => { assert( "removeSubscription expects a subscriptions array", - this.subscriptions + this.subscriptions, ); let cached = this.subscriptions; let subscriptionToRemove = this.subscriptions.find( - (subscription) => subscription.productArea === productArea + (subscription) => subscription.productArea === productArea, ); assert( "removeSubscription expects a valid productArea", - subscriptionToRemove + subscriptionToRemove, ); this.subscriptions.removeObject(subscriptionToRemove); try { - await this.fetchSvc.fetch("/api/v1/me/subscriptions", { - method: "POST", - headers: this.subscriptionsPostHeaders, - body: this.subscriptionsPostBody, - }); + await this.fetchSvc.fetch( + `/api/${this.configSvc.config.api_version}/me/subscriptions`, + { + method: "POST", + headers: this.subscriptionsPostHeaders, + body: this.subscriptionsPostBody, + }, + ); } catch (e: unknown) { console.error("Error updating subscriptions: ", e); this.subscriptions = cached; throw e; } - } + }, ); } diff --git a/web/app/services/config.ts b/web/app/services/config.ts index 47a36594e..4fb29fca5 100644 --- a/web/app/services/config.ts +++ b/web/app/services/config.ts @@ -9,6 +9,7 @@ export default class ConfigService extends Service { algolia_docs_index_name: config.algolia.docsIndexName, algolia_drafts_index_name: config.algolia.draftsIndexName, algolia_internal_index_name: config.algolia.internalIndexName, + api_version: "v1", feature_flags: config.featureFlags, google_doc_folders: config.google.docFolders ?? "", short_link_base_url: config.shortLinkBaseURL, @@ -21,6 +22,12 @@ export default class ConfigService extends Service { setConfig(param) { this.set("config", param); + + // Set API version. + this.config["api_version"] = "v1"; + if (this.config.feature_flags["api_v2"]) { + this.config["api_version"] = "v2"; + } } } diff --git a/web/app/services/product-areas.ts b/web/app/services/product-areas.ts index 875d75fc1..a96936d72 100644 --- a/web/app/services/product-areas.ts +++ b/web/app/services/product-areas.ts @@ -3,6 +3,7 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import RouterService from "@ember/routing/router-service"; import { task, timeout } from "ember-concurrency"; +import ConfigService from "hermes/services/config"; import FetchService from "./fetch"; export type ProductArea = { @@ -10,6 +11,7 @@ export type ProductArea = { }; export default class ProductAreasService extends Service { + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; @tracked index: Record | null = null; @@ -17,7 +19,7 @@ export default class ProductAreasService extends Service { fetch = task(async () => { try { this.index = await this.fetchSvc - .fetch("/api/v1/products") + .fetch(`/api/${this.configSvc.config.api_version}/products`) .then((resp) => resp?.json()); } catch (err) { this.index = null; diff --git a/web/app/services/recently-viewed-docs.ts b/web/app/services/recently-viewed-docs.ts index 83542f212..86aa7d2ba 100644 --- a/web/app/services/recently-viewed-docs.ts +++ b/web/app/services/recently-viewed-docs.ts @@ -3,6 +3,7 @@ import { inject as service } from "@ember/service"; import { keepLatestTask } from "ember-concurrency"; import FetchService from "./fetch"; import { tracked } from "@glimmer/tracking"; +import ConfigService from "hermes/services/config"; import { HermesDocument } from "hermes/types/document"; import { assert } from "@ember/debug"; @@ -20,6 +21,7 @@ export type RecentlyViewedDoc = { }; export default class RecentlyViewedDocsService extends Service { + @service("config") declare configSvc: ConfigService; @service("fetch") declare fetchSvc: FetchService; @service declare session: any; @@ -47,7 +49,7 @@ export default class RecentlyViewedDocsService extends Service { * Fetch the file IDs from the backend. */ let fetchResponse = await this.fetchSvc.fetch( - "/api/v1/me/recently-viewed-docs" + `/api/${this.configSvc.config.api_version}/me/recently-viewed-docs`, ); this.index = (await fetchResponse?.json()) || []; @@ -68,13 +70,15 @@ export default class RecentlyViewedDocsService extends Service { (this.index as IndexedDoc[]).map(async ({ id, isDraft }) => { let endpoint = isDraft ? "drafts" : "documents"; let doc = await this.fetchSvc - .fetch(`/api/v1/${endpoint}/${id}`) + .fetch( + `/api/${this.configSvc.config.api_version}/${endpoint}/${id}`, + ) .then((resp) => resp?.json()); doc.isDraft = isDraft; return { doc, isDraft }; - }) + }), ); /** * Set up an empty array to hold the documents.