Skip to content

Commit 508edf0

Browse files
committed
Make federation immutable
1 parent 8fd319a commit 508edf0

File tree

5 files changed

+89
-39
lines changed

5 files changed

+89
-39
lines changed

package-lock.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@
1919
"@babel/preset-typescript": "^7.26.0",
2020
"@jest/globals": "^29.7.0",
2121
"@types/jest": "^29.5.8",
22+
"@types/lodash-es": "^4.17.12",
23+
"i": "^0.3.7",
2224
"jest": "^29.7.0",
2325
"jest-environment-jsdom": "^29.7.0",
2426
"ts-jest": "^29.1.1",
2527
"typescript": "^5.2.2"
28+
},
29+
"dependencies": {
30+
"lodash-es": "^4.17.21"
2631
}
2732
}

src/Client.ts

Lines changed: 45 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
'use client';
12

23
import {
34
Federation,
@@ -6,6 +7,9 @@ import {
67
ObjectList,
78
TokenPermission
89
} from "./types"
10+
import {
11+
cloneDeep
12+
} from 'lodash-es';
913
import {
1014
fetchNamespace,
1115
getObjectToken,
@@ -24,14 +28,14 @@ import sessionObject, {ProxiedValue} from "./sessionObject";
2428

2529
export default class Client {
2630

27-
federations: Record<string, Federation>
31+
federations: ProxiedValue<Record<string, Federation>>
2832
prefixToNamespace: Record<string, string>
2933
requestQueue: ProxiedValue<QueuedRequest[]>
3034
codeVerifier: ProxiedValue<string>
3135

3236
constructor() {
3337
// Set up and load/initialize session storage objects
34-
this.federations = sessionObject<Record<string, Federation>>("federations")
38+
this.federations = sessionObject<ProxiedValue<Record<string, Federation>>>("federations", {value: {}})
3539
this.prefixToNamespace = sessionObject<Record<string, string>>('prefixToNamespace')
3640
this.requestQueue = sessionObject<ProxiedValue<QueuedRequest[]>>("requestQueue", {value: []})
3741
this.codeVerifier = sessionObject<ProxiedValue<string>>("codeVerifier", {value: generateCodeVerifier()})
@@ -48,8 +52,8 @@ export default class Client {
4852

4953
const {federationHostname, objectPath} = parsePelicanObjectUrl(objectUrl)
5054
const federation = await this.getFederation(federationHostname)
51-
const namespace = await this.getNamespace(objectPath, federation)
52-
const token = await getObjectToken(objectPath, namespace)
55+
const namespace = await this.getNamespace(objectUrl, federation)
56+
const token = await getObjectToken(namespace)
5357

5458
const objectHttpUrl = new URL(`${federation.configuration.director_endpoint}${objectPath}`)
5559
const response = await fetch(objectHttpUrl, {
@@ -63,17 +67,17 @@ export default class Client {
6367

6468
// If we get a 403, queue this request call it after getting a token
6569
} else if(response.status === 403 && !token){
66-
await this.queueRequestAndStartFlow(objectPath, federation)
70+
await this.queueRequestAndStartFlow(objectUrl, federation)
6771
} else {
6872
throw new Error(`Could not get object: ${response.status} ${response.statusText}`)
6973
}
7074
}
7175

72-
async list(federationPath: string) : Promise<ObjectList[] | undefined> {
73-
const {federationHostname, objectPath} = parsePelicanObjectUrl(federationPath)
76+
async list(collectionUrl: string) : Promise<ObjectList[] | undefined> {
77+
const {federationHostname, objectPath} = parsePelicanObjectUrl(collectionUrl)
7478
const federation = await this.getFederation(federationHostname)
75-
const namespace = await this.getNamespace(objectPath, federation)
76-
const token = await getObjectToken(objectPath, namespace)
79+
const namespace = await this.getNamespace(collectionUrl, federation)
80+
const token = await getObjectToken(namespace)
7781

7882
const objectHttpUrl = new URL(`${federation.configuration.director_endpoint}${objectPath}`)
7983
const response = await fetch(objectHttpUrl, {
@@ -89,7 +93,7 @@ export default class Client {
8993

9094
// If we get a 403, queue this request call it after getting a token
9195
} else if(response.status === 403){
92-
await this.queueRequestAndStartFlow(objectPath, federation)
96+
await this.queueRequestAndStartFlow(collectionUrl, federation)
9397
} else {
9498
throw new Error(`Could not list directory: ${response.status} ${response.statusText}`)
9599
}
@@ -99,8 +103,8 @@ export default class Client {
99103

100104
const { federationHostname, objectPath } = parsePelicanObjectUrl(objectUrl)
101105
const federation = await this.getFederation(federationHostname)
102-
const namespace = await this.getNamespace(objectPath, federation)
103-
const token = await getObjectToken(objectPath, namespace)
106+
const namespace = await this.getNamespace(objectUrl, federation)
107+
const token = await getObjectToken(namespace)
104108

105109
const objectHttpUrl = new URL(`${federation.configuration.director_endpoint}${objectPath}`)
106110
const response = await fetch(objectHttpUrl, {
@@ -114,7 +118,7 @@ export default class Client {
114118
if (response.status === 200 || response.status === 201) {
115119
return
116120
} else if (response.status === 403) {
117-
await this.queueRequestAndStartFlow(objectPath, federation)
121+
await this.queueRequestAndStartFlow(objectUrl, federation)
118122
} else {
119123
throw new Error(`Could not upload object: ${response.status} ${response.statusText}`)
120124
}
@@ -125,10 +129,10 @@ export default class Client {
125129
* @param objectUrl
126130
*/
127131
async permissions(objectUrl: string): Promise<TokenPermission[]> {
128-
const { federationHostname, objectPath } = parsePelicanObjectUrl(objectUrl)
132+
const { federationHostname } = parsePelicanObjectUrl(objectUrl)
129133
const federation = await this.getFederation(federationHostname)
130-
const namespace = await this.getNamespace(objectPath, federation)
131-
const token = await getObjectToken(objectPath, namespace)
134+
const namespace = await this.getNamespace(objectUrl, federation)
135+
const token = await getObjectToken(namespace)
132136

133137
if(!token) return []
134138

@@ -148,13 +152,15 @@ export default class Client {
148152
try {
149153
// Get the namespace and federation from the state parameter
150154
const {federation: federationHostname, namespace: namespacePrefix} = parseOauthState(new URL(window.location.href))
151-
const namespace = this.federations[federationHostname]?.namespaces[namespacePrefix]
155+
const namespace = this.federations.value[federationHostname]?.namespaces[namespacePrefix]
152156

153157
// Check if we have an auth code to exchange for a token
154158
const token = await getToken(namespace.oidcConfiguration, this.codeVerifier.value, namespace.clientId, namespace.clientSecret, authCode)
155159

156160
// Save the token to the namespace
157-
this.federations[federationHostname].namespaces[namespacePrefix].token = token.accessToken
161+
const federationsClone = cloneDeep(this.federations.value)
162+
federationsClone[federationHostname].namespaces[namespacePrefix].token = token.accessToken
163+
this.federations.value = federationsClone
158164
} catch {}
159165

160166
// Clean up the window
@@ -166,20 +172,23 @@ export default class Client {
166172
* @param federationHostname
167173
*/
168174
async getFederation(federationHostname: string) : Promise<Federation> {
169-
if(!this.federations?.[federationHostname]) {
170-
this.federations[federationHostname] = await fetchFederationConfiguration(federationHostname)
175+
if(!this.federations.value?.[federationHostname]) {
176+
this.federations.value = {
177+
...this.federations.value,
178+
[federationHostname]: await fetchFederationConfiguration(federationHostname)
179+
}
171180
}
172-
return this.federations[federationHostname]
181+
return this.federations.value[federationHostname]
173182
}
174183

175184
/**
176185
* Get a namespace's configuration from the federation and save it to session cache
177-
* @param objectPath
186+
* @param objectUrl
178187
* @param federation
179188
*/
180-
async getNamespace(objectPath: string, federation: Federation) : Promise<Namespace> {
189+
async getNamespace(objectUrl: string, federation: Federation) : Promise<Namespace> {
181190

182-
const {objectPrefix} = parsePelicanObjectUrl(objectPath)
191+
const {objectPrefix, objectPath} = parsePelicanObjectUrl(objectUrl)
183192

184193
// Check if we have already cached the namespace for this prefix
185194
if(this.prefixToNamespace?.[objectPrefix]){
@@ -192,33 +201,37 @@ export default class Client {
192201
// Fetch and save the namespace information
193202
const requestNamespace = await fetchNamespace(objectPath, federation)
194203
this.prefixToNamespace[objectPrefix] = requestNamespace.prefix
195-
this.federations[federation.hostname].namespaces[requestNamespace.prefix] = requestNamespace
204+
const federationsClone = cloneDeep(this.federations.value)
205+
federationsClone[federation.hostname].namespaces[requestNamespace.prefix] = requestNamespace
206+
this.federations.value = federationsClone
207+
196208
return requestNamespace
197209
}
198210

199211
/**
200212
* Queue a request that needs authorization and start the authorization code flow
201-
* @param objectPath
213+
* @param objectUrl
202214
* @param federation
203215
*/
204-
async queueRequestAndStartFlow(objectPath: string, federation: Federation) : Promise<void> {
216+
async queueRequestAndStartFlow(objectUrl: string, federation: Federation) : Promise<void> {
205217

206218
// Fetch and save the namespace information
207-
const requestNamespace = await fetchNamespace(objectPath, federation)
208-
this.federations[federation.hostname].namespaces[requestNamespace.prefix] = requestNamespace
219+
const namespace = await this.getNamespace(objectUrl, federation)
220+
221+
const {objectPath} = parsePelicanObjectUrl(objectUrl)
209222

210223
// Queue the file request
211224
this.requestQueue.value.push({
212-
objectUrl: `pelican://${federation.hostname}${objectPath}`,
225+
objectUrl,
213226
federationHostname: federation.hostname,
214227
path: objectPath,
215-
namespace: requestNamespace.prefix,
228+
namespace: namespace.prefix,
216229
type: "GET",
217230
createdAt: new Date().getTime()
218231
})
219232

220233
// Start the authorization code flow
221-
await this.startAuthorizationCodeFlow(objectPath, requestNamespace, federation)
234+
await this.startAuthorizationCodeFlow(objectPath, namespace, federation)
222235
}
223236

224237
/**

src/Pelican/getObjectToken.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import {Namespace, Token} from "../types";
33
/**
44
* Get the best fit token for the given object path and federation or undefined if none are readily available
55
*
6-
* @param objectPath Object path - pelican://<federation-hostname>/<object-path>
76
* @param namespace Federation hosting the requested object
87
*/
9-
const getObjectToken = async (objectPath: string, namespace: Namespace) : Promise<Token | undefined> => {
8+
const getObjectToken = async (namespace: Namespace) : Promise<Token | undefined> => {
109

1110
// Check if we have a token for this namespace and that it is not expired
1211
if(namespaceTokenIsExpired(namespace)){

website/src/app/page.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Box from "@mui/material/Box";
44
import {TextField, Typography, Autocomplete, createFilterOptions} from "@mui/material";
55
import { Button} from "@mui/material";
66
import {Grid} from "@mui/material";
7-
import {useEffect, useState} from "react";
7+
import {useEffect, useMemo, useState} from "react";
88
import Client from "../../../src/Client";
99
import { ObjectList, TokenPermission } from "../../../src/types"
1010
import Card from "@mui/material/Card";
@@ -47,10 +47,9 @@ export default function Home() {
4747
} else {
4848
await client?.get(objectPath)
4949
}
50-
51-
setClient(new Client());
5250
}
5351

52+
const federations = useMemo(() => client?.federations.value, [client?.federations.value]);
5453

5554
return (
5655
<Box minHeight={"90vh"}>
@@ -84,7 +83,7 @@ export default function Home() {
8483
<Button variant="contained" onClick={submit}>{object ? 'Upload' : 'Download'}</Button>
8584
<Button onClick={() => {
8685
if(client) {
87-
Object.keys(client.federations).forEach(k => delete client?.federations[k]);
86+
client.federations.value = {};
8887
}
8988
}}>Clear Federations</Button>
9089
</Box>
@@ -117,7 +116,7 @@ export default function Home() {
117116
<Box overflow={'auto'}>
118117
<pre>
119118
<code>
120-
{JSON.stringify(client?.federations, null, 2)}
119+
{JSON.stringify(federations, null, 2)}
121120
</code>
122121
</pre>
123122
</Box>

0 commit comments

Comments
 (0)