diff --git a/src/app/middleware/localStorageMiddleware.ts b/src/app/middleware/localStorageMiddleware.ts index a12673fe1a..92530aa4e4 100644 --- a/src/app/middleware/localStorageMiddleware.ts +++ b/src/app/middleware/localStorageMiddleware.ts @@ -7,9 +7,11 @@ import { CURRENT_THEME } from '../services/graph-constants'; import { getUniquePaths } from '../services/reducers/collections-reducer.util'; import { CHANGE_THEME_SUCCESS, COLLECTION_CREATE_SUCCESS, + ODATA_ABNF_RULES_CREATE_SUCCESS, RESOURCEPATHS_ADD_SUCCESS, RESOURCEPATHS_DELETE_SUCCESS, SAMPLES_FETCH_SUCCESS } from '../services/redux-constants'; import { saveToLocalStorage } from '../utils/local-storage'; +import { odataAbnfCache } from '../../modules/cache/odataAbnfRules.cache'; const localStorageMiddleware: Middleware<{}, any, Dispatch> = () => (next) => async (value) => { const action = value as AppAction; @@ -49,6 +51,10 @@ const localStorageMiddleware: Middleware<{}, any, Dispatch> = () break; } + case ODATA_ABNF_RULES_CREATE_SUCCESS: + odataAbnfCache.saveGrammar(action.payload as string); + break; + default: break; } diff --git a/src/app/services/actions/autocomplete-action-creators.spec.ts b/src/app/services/actions/autocomplete-action-creators.spec.ts index e9b932fafa..38142adf4f 100644 --- a/src/app/services/actions/autocomplete-action-creators.spec.ts +++ b/src/app/services/actions/autocomplete-action-creators.spec.ts @@ -104,7 +104,8 @@ const mockState: ApplicationState = { error: null }, collections: [], - proxyUrl: '' + proxyUrl: '', + odataAbnf: '' } store.getState = () => ({ diff --git a/src/app/services/actions/permissions-action-creator.spec.ts b/src/app/services/actions/permissions-action-creator.spec.ts index 999336a6b6..614e9540c9 100644 --- a/src/app/services/actions/permissions-action-creator.spec.ts +++ b/src/app/services/actions/permissions-action-creator.spec.ts @@ -125,7 +125,8 @@ const mockState: ApplicationState = { error: null }, collections: [], - proxyUrl: '' + proxyUrl: '', + odataAbnf: '' } const currentState = store.getState(); store.getState = () => { diff --git a/src/app/services/actions/query-action-creators.spec.ts b/src/app/services/actions/query-action-creators.spec.ts index 1641476cd9..9fd7822f2b 100644 --- a/src/app/services/actions/query-action-creators.spec.ts +++ b/src/app/services/actions/query-action-creators.spec.ts @@ -4,7 +4,7 @@ import configureMockStore from 'redux-mock-store'; import { ADD_HISTORY_ITEM_SUCCESS, QUERY_GRAPH_RUNNING, QUERY_GRAPH_STATUS, QUERY_GRAPH_SUCCESS } from '../redux-constants'; import { runQuery } from '../slices/graph-response.slice'; import { mockThunkMiddleware } from './mockThunkMiddleware'; -import { AnyAction } from '@reduxjs/toolkit'; +import { Action } from '@reduxjs/toolkit'; import { IQuery } from '../../../types/query-runner'; const mockStore = configureMockStore([mockThunkMiddleware]); @@ -68,7 +68,7 @@ describe('Query action creators', () => { selectedVersion: 'v1.0' } const store_ = mockStore({ graphResponse: '' }); - store_.dispatch(runQuery(query) as unknown as AnyAction); + store_.dispatch(runQuery(query) as unknown as Action); expect(store_.getActions().map(action => { const { meta, ...rest } = action; return rest; @@ -125,7 +125,7 @@ describe('Query action creators', () => { } const store_ = mockStore({ graphResponse: '' }); - store_.dispatch(runQuery(query) as unknown as AnyAction); + store_.dispatch(runQuery(query) as unknown as Action); const mockFetch = jest.fn().mockImplementation(() => { return Promise.resolve({ ok: false, @@ -137,7 +137,7 @@ describe('Query action creators', () => { window.fetch = mockFetch; - store_.dispatch(runQuery(query) as unknown as AnyAction) + store_.dispatch(runQuery(query) as unknown as Action) .then((response: { type: any; payload: { ok: boolean; }; }) => { expect(response.type).toBe(QUERY_GRAPH_STATUS); expect(response.payload.ok).toBe(false); diff --git a/src/app/services/actions/resource-explorer-action-creators.spec.ts b/src/app/services/actions/resource-explorer-action-creators.spec.ts index edba19e182..cc210e7aa8 100644 --- a/src/app/services/actions/resource-explorer-action-creators.spec.ts +++ b/src/app/services/actions/resource-explorer-action-creators.spec.ts @@ -100,7 +100,8 @@ const mockState: ApplicationState = { error: null }, collections: [], - proxyUrl: '' + proxyUrl: '', + odataAbnf: '' } const paths = [ diff --git a/src/app/services/graph-constants.ts b/src/app/services/graph-constants.ts index bdc5315221..05cfac1059 100644 --- a/src/app/services/graph-constants.ts +++ b/src/app/services/graph-constants.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ export const GRAPH_URL = 'https://graph.microsoft.com'; export const GRAPH_API_VERSIONS = ['v1.0', 'beta']; export const USER_INFO_URL = `${GRAPH_URL}/v1.0/me`; @@ -32,4 +33,6 @@ export const ADMIN_CONSENT_DOC_LINK = 'https://learn.microsoft.com/en-us/graph/s // eslint-disable-next-line max-len export const CONSENT_TYPE_DOC_LINK = 'https://learn.microsoft.com/en-us/graph/api/resources/oauth2permissiongrant?view=graph-rest-1.0#:~:text=(eq%20only).-,consentType,-String' export const CURRENT_THEME='CURRENT_THEME'; -export const EXP_URL='https://default.exp-tas.com/exptas76/9b835cbf-9742-40db-84a7-7a323a77f3eb-gedev/api/v1/tas' \ No newline at end of file +export const EXP_URL='https://default.exp-tas.com/exptas76/9b835cbf-9742-40db-84a7-7a323a77f3eb-gedev/api/v1/tas' +export const ODATA_ABNF_CONSTRUCTION_RULES_ENDPOINT = 'https://raw.githubusercontent.com/oasis-tcs/odata-abnf/refs/heads/main/abnf/odata-abnf-construction-rules.txt' +export const ODATA_ABNF_RULES_OBJECT_KEY = 'odata-abnf-rules-object'; \ No newline at end of file diff --git a/src/app/services/reducers/index.ts b/src/app/services/reducers/index.ts index 02beeb2b20..1742e2bd0b 100644 --- a/src/app/services/reducers/index.ts +++ b/src/app/services/reducers/index.ts @@ -20,6 +20,7 @@ import snippets from '../slices/snippet.slice'; import themeChange from '../slices/theme.slice'; import termsOfUse from '../slices/terms-of-use.slice'; import sidebarProperties from '../slices/sidebar-properties.slice'; +import odataAbnf from '../slices/odataabnf.slice'; const reducers = { auth, @@ -42,7 +43,8 @@ const reducers = { sidebarProperties, snippets, termsOfUse, - theme: themeChange + theme: themeChange, + odataAbnf }; export { diff --git a/src/app/services/redux-constants.ts b/src/app/services/redux-constants.ts index b0c0cb6246..7e77712f16 100644 --- a/src/app/services/redux-constants.ts +++ b/src/app/services/redux-constants.ts @@ -56,3 +56,4 @@ export const REVOKE_SCOPES_PENDING = 'auth/revokeScopes/pending'; export const REVOKE_SCOPES_SUCCESS = 'auth/revokeScopes/fulfilled'; export const REVOKE_SCOPES_ERROR = 'auth/revokeScopes/rejected'; export const COLLECTION_CREATE_SUCCESS = 'collections/createCollection'; +export const ODATA_ABNF_RULES_CREATE_SUCCESS = 'odataAbnf/getRulesText/fulfilled'; diff --git a/src/app/services/slices/odataabnf.slice.ts b/src/app/services/slices/odataabnf.slice.ts new file mode 100644 index 0000000000..c86964b9bd --- /dev/null +++ b/src/app/services/slices/odataabnf.slice.ts @@ -0,0 +1,40 @@ +import { createAsyncThunk, createSlice, PayloadAction} from '@reduxjs/toolkit'; +import { ODATA_ABNF_CONSTRUCTION_RULES_ENDPOINT } from '../graph-constants'; + +const initialState = '' + +export const getRulesText = createAsyncThunk( + 'odataAbnf/getRulesText', + async (_, { rejectWithValue }) => { + try { + const response = await fetch(ODATA_ABNF_CONSTRUCTION_RULES_ENDPOINT); + if (!response.ok) { + throw new Error('Failed to fetch the OData ABNF construction rules'); + } + return await response.text(); + } catch (error) { + return rejectWithValue(ODATA_ABNF_CONSTRUCTION_RULES_ENDPOINT); + } + } +); + +const odataAbnfSlice = createSlice({ + name: 'getRules', + initialState, + reducers: { + getRules:(_state, action: PayloadAction) => { + return action.payload; + } + }, + extraReducers: (builder)=> { + builder.addCase(getRulesText.fulfilled, (_state, action)=>{ + return action.payload; + }); + builder.addCase(getRulesText.rejected, (_state, action)=>{ + return action.payload as string; + }) + } +}) + +export const {getRules} = odataAbnfSlice.actions; +export default odataAbnfSlice.reducer; diff --git a/src/index.tsx b/src/index.tsx index db0ba7aca1..7322d0cd04 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,7 +7,7 @@ import { Provider } from 'react-redux'; import App from './app/views/App'; -import { CURRENT_THEME } from './app/services/graph-constants'; +import { CURRENT_THEME, ODATA_ABNF_RULES_OBJECT_KEY } from './app/services/graph-constants'; import { getAuthTokenSuccess, getConsentedScopesSuccess } from './app/services/slices/auth.slice'; import { createCollection } from './app/services/slices/collections.slice'; import { setDevxApiUrl } from './app/services/slices/devxapi.slice'; @@ -18,6 +18,7 @@ import { fetchResources } from './app/services/slices/resources.slice'; import { setSampleQuery } from './app/services/slices/sample-query.slice'; import { toggleSidebar } from './app/services/slices/sidebar-properties.slice'; import { changeTheme } from './app/services/slices/theme.slice'; +import { getRulesText } from './app/services/slices/odataabnf.slice'; import variantService from './app/services/variant-service'; import { isValidHttpsUrl } from './app/utils/external-link-validation'; import { readFromLocalStorage } from './app/utils/local-storage'; @@ -33,6 +34,7 @@ import { IDevxAPI } from './types/devx-api'; import { Mode } from './types/enums'; import { IHistoryItem } from './types/history'; import { Collection } from './types/resources'; +import { odataAbnfCache } from './modules/cache/odataAbnfRules.cache'; const appRoot: HTMLElement = document.getElementById('root')!; @@ -98,6 +100,11 @@ const appStore: any = store; setCurrentSystemTheme(); appStore.dispatch(getGraphProxyUrl()); +const odataAbnfRules = await odataAbnfCache.readGrammar() +if (Object.keys(odataAbnfRules).length === 0) { + appStore.dispatch(getRulesText()) +} + function refreshAccessToken() { authenticationWrapper.getToken().then((authResponse: AuthenticationResult) => { if (authResponse && authResponse.accessToken) { diff --git a/src/modules/cache/odataAbnfRules.cache.ts b/src/modules/cache/odataAbnfRules.cache.ts new file mode 100644 index 0000000000..2b333736ab --- /dev/null +++ b/src/modules/cache/odataAbnfRules.cache.ts @@ -0,0 +1,32 @@ +import 'apg-js/dist/apg-api-bundle'; +import localforage from 'localforage'; +import { ODATA_ABNF_RULES_OBJECT_KEY } from '../../app/services/graph-constants'; + +const odataAbnfStorage = localforage.createInstance({ + storeName: 'odataAbnf', + name: 'GE_V4' +}); + +const { apgApi } = globalThis as any; + + +export const odataAbnfCache = (function () { + const saveGrammar = async (text: string)=>{ + const api = new apgApi(text) + api.generate() + if (api.errors.length > 0) { + throw new Error('ABNF grammar has failed to generate') + } + // Note: stringifying here loses the callbacks and toString function. They're not needed for this + // therefore this is ok. + await odataAbnfStorage.setItem(ODATA_ABNF_RULES_OBJECT_KEY, JSON.stringify(api.toObject())) + } + + const readGrammar = async (): Promise =>{ + const grammar = await odataAbnfStorage.getItem(ODATA_ABNF_RULES_OBJECT_KEY) as string; + if (grammar) {return JSON.parse(grammar);} + return {}; + } + + return {saveGrammar, readGrammar} +})() \ No newline at end of file diff --git a/src/modules/validation/abnf.ts b/src/modules/validation/abnf.ts index 1c74b92cf8..19c38bb702 100644 --- a/src/modules/validation/abnf.ts +++ b/src/modules/validation/abnf.ts @@ -1,5 +1,5 @@ import 'apg-js/dist/apg-api-bundle'; -import { rules } from './definition'; +import { odataAbnfCache } from '../cache/odataAbnfRules.cache'; interface ValidationResult { inputLength: number; @@ -15,36 +15,40 @@ interface ValidationResult { success: boolean; } -const { apgLib, apgApi } = globalThis as any; +const { apgLib } = globalThis as any; export class ValidatedUrl { private static grammar: any; private static parser = new apgLib.parser(); public static getGrammar() { if (!ValidatedUrl.grammar) { - ValidatedUrl.grammar = this.generateGrammarObject(); + this.generateGrammarObject().then(grammar=>{ ValidatedUrl.grammar = grammar}); } return ValidatedUrl.grammar; } - - private static generateGrammarObject() { - const api = new apgApi(rules); - api.generate(); - - if (api.errors.length) { - throw Error('ABNF grammar has errors'); - } - return api.toObject(); + private static async generateGrammarObject() { + const grammar = await odataAbnfCache.readGrammar(); + return grammar; } public validate(graphUrl: string): ValidationResult { let decodedGraphUrl = graphUrl; try { decodedGraphUrl = decodeURI(graphUrl); } catch (error) { /* empty */ } - const result = ValidatedUrl.parser.parse( - ValidatedUrl.getGrammar(), + const grammar = ValidatedUrl.getGrammar() + let result = ValidatedUrl.parser.parse( + grammar, 'odataUri', decodedGraphUrl ); + + if (!result.success) { + const pathname = new URL(decodedGraphUrl).pathname.replace('/v1.0/','').replace('/beta/', ''); + result = ValidatedUrl.parser.parse( + grammar, + 'odataRelativeUri', + pathname + ); + } return result; } } \ No newline at end of file