Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions app/components/Launcher.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Loading from "@ogw_front/components/Loading.vue";
import Recaptcha from "@ogw_front/components/Recaptcha.vue";
import { Status } from "@ogw_front/utils/status";
import { appMode } from "@ogw_shared/app_mode";
import { getCloudUrlParam } from "@ogw_front/utils/cloud";
import { useInfraStore } from "@ogw_front/stores/infra";

interface Props {
Expand All @@ -15,8 +16,10 @@ interface Props {
const { appName, email = undefined, isUserAuthenticated = false, logo = "" } = defineProps<Props>();

const infraStore = useInfraStore();
if (infraStore.app_mode !== appMode.CLOUD) {
infraStore.create_backend();
// In cloud mode, wait for the user to authenticate and load the app, unless a `?cloud_url=` link
// Points at an already running service: that one is connected straight away, without authentication.
if (infraStore.app_mode !== appMode.CLOUD || getCloudUrlParam() !== undefined) {
infraStore.create_backend(email);
}

function cloudCreateBackend(): Promise<void> {
Expand Down
16 changes: 10 additions & 6 deletions app/stores/back.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ export const useBackStore = defineStore("back", {
return getRestApiPort(this.default_local_port);
},
base_url(): string {
const infraStore = useInfraStore();
let back_url = `${this.protocol}://${infraStore.domain_name}:${this.port}`;
if (isCloudMode()) {
back_url += `/geode`;
}
return back_url;
return this.base_url_for(useInfraStore().domain_name);
},
base_url_for(): (domain_name: string) => string {
return (domain_name: string) => {
let back_url = `${this.protocol}://${domain_name}:${this.port}`;
if (isCloudMode()) {
back_url += `/geode`;
}
return back_url;
};
},
is_busy(): boolean {
return this.request_counter > 0;
Expand Down
74 changes: 59 additions & 15 deletions app/stores/cloud.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { clearCloudUrlParam, getCloudUrlParam } from "@ogw_front/utils/cloud";
import { Status } from "@ogw_front/utils/status";
import { api_fetch } from "@ogw_internal/utils/api_fetch";
import back_schemas from "@geode/opengeodeweb-back/opengeodeweb_back_schemas.json";
import { setAppBaseUrl } from "@ogw_shared/scripts";
import { useAPIStore } from "@ogw_front/stores/api";
import { useFeedbackStore } from "@ogw_front/stores/feedback";

const run_cloud_schema = {
$id: "/cloud/run",
Expand All @@ -20,13 +24,24 @@ export const useCloudStore = defineStore("cloud", {
status: Status.NOT_CONNECTED,
}),
actions: {
// Reuses the running service given by `?cloud_url=` when present, otherwise launches a new one.
// The param is single use: it is cleared whatever the outcome, so a retry launches a new service.
async start(email: string) {
const existing_host = getCloudUrlParam();
if (existing_host === undefined) {
await this.launch(email);
return;
}
try {
await this.connect_existing(existing_host);
} finally {
clearCloudUrlParam();
}
},
async launch(email: string) {
this.status = Status.CONNECTING;
const { PROJECT, BRANCH } = useRuntimeConfig().public;
const params = { email, project: PROJECT, branch: BRANCH };
const { useAppStore } = await import("./app");
const { useFeedbackStore } = await import("./feedback");
const appStore = useAppStore();
const feedbackStore = useFeedbackStore();
const APIStore = useAPIStore();
const result = await APIStore.request(
Expand All @@ -45,18 +60,7 @@ export const useCloudStore = defineStore("cloud", {
) {
return;
}
const { url } = response;
feedbackStore.$patch({ server_error: false });
this.status = Status.CONNECTED;
const { useInfraStore } = await import("./infra");
const infraStore = useInfraStore();
infraStore.$patch({
domain_name: url,
});
setAppBaseUrl(appStore.base_url).catch(() => undefined);
appStore.$patch({
projectFolderPath: "/project",
});
await this.on_connected(response.url);
},
response_error_function: () => {
feedbackStore.$patch({ server_error: true });
Expand All @@ -66,6 +70,46 @@ export const useCloudStore = defineStore("cloud", {
);
return result;
},
async connect_existing(host: string) {
this.status = Status.CONNECTING;
const { useBackStore } = await import("./back");
const backStore = useBackStore();
// Probe the candidate host directly so domain_name is only updated once the service answers.
try {
await api_fetch(
{
$id: backStore.$id,
base_url: backStore.base_url_for(host),
start_request: () => {
backStore.start_request();
},
stop_request: () => {
backStore.stop_request();
},
},
{ schema: back_schemas.opengeodeweb_back.ping },
);
} catch (error) {
useFeedbackStore().$patch({ server_error: true });
this.status = Status.NOT_CONNECTED;
throw error;
}
await this.on_connected(host);
},
async on_connected(url: string) {
const { useAppStore } = await import("./app");
const { useInfraStore } = await import("./infra");
const appStore = useAppStore();
useFeedbackStore().$patch({ server_error: false });
this.status = Status.CONNECTED;
useInfraStore().$patch({
domain_name: url,
});
appStore.$patch({
projectFolderPath: "/project",
});
await setAppBaseUrl(appStore.base_url);
},
connect() {
this.status = Status.CONNECTED;
},
Expand Down
8 changes: 7 additions & 1 deletion app/stores/infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ export const useInfraStore = defineStore("infra", {
this.status = Status.CREATING;
if (this.app_mode === appMode.CLOUD) {
const cloudStore = useCloudStore();
await cloudStore.launch(email ?? "");
try {
await cloudStore.start(email ?? "");
} catch (error) {
// Back to the "Load the app" button so the user is not stuck on the loading screen.
this.status = Status.NOT_CREATED;
throw error;
}
} else {
const { useAppStore } = await import("@ogw_front/stores/app");
const appStore = useAppStore();
Expand Down
35 changes: 35 additions & 0 deletions app/utils/cloud.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const CLOUD_URL_PARAM = "cloud_url";
const CLOUD_RUN_HOST_SUFFIX = ".run.app";

// Hostname of an already running Cloud Run service passed as `?cloud_url=`, restricted to Cloud Run hosts so a crafted link cannot point the app at an arbitrary server.
function getCloudUrlParam(): string | undefined {
const value = new URLSearchParams(globalThis.location.search).get(CLOUD_URL_PARAM);
if (value === null || value === "") {
return undefined;
}
const url_string = value.includes("://") ? value : `https://${value}`;
let hostname = "";
try {
({ hostname } = new URL(url_string));
} catch {
console.warn(`[Cloud] Invalid ${CLOUD_URL_PARAM}:`, value);
return undefined;
}
if (!hostname.endsWith(CLOUD_RUN_HOST_SUFFIX)) {
console.warn(`[Cloud] Ignoring ${CLOUD_URL_PARAM}, not a Cloud Run host:`, hostname);
return undefined;
}
return hostname;
}

// Drops `?cloud_url=` from the address bar, keeping the router's history state, so a later "Load the app" launches a new service.
function clearCloudUrlParam(): void {
const url = new URL(globalThis.location.href);
if (!url.searchParams.has(CLOUD_URL_PARAM)) {
return;
}
url.searchParams.delete(CLOUD_URL_PARAM);
globalThis.history.replaceState(globalThis.history.state, "", url);
}

export { clearCloudUrlParam, getCloudUrlParam };
148 changes: 144 additions & 4 deletions tests/unit/stores/cloud.nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
// oxlint-disable vitest/require-mock-type-parameters
// Third party imports
import { $fetch, type FetchContext, type FetchResponse } from "ofetch";
import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest";

// Local imports
import { Status } from "@ogw_front/utils/status";
import { appMode } from "@ogw_shared/app_mode";
import { setupActivePinia } from "@ogw_tests/utils";
import { useCloudStore } from "@ogw_front/stores/cloud";
import { useFeedbackStore } from "@ogw_front/stores/feedback";
Expand All @@ -14,7 +15,11 @@ import { useInfraStore } from "@ogw_front/stores/infra";
// A hand-built `$Fetch`: a mock function plus the `raw`/`native`/`create` members the real
// `ofetch` export carries, so it satisfies the real type directly (no unsafe cast needed).
vi.mock(import("ofetch"), () => ({
$fetch: Object.assign(vi.fn(), { raw: vi.fn(), native: vi.fn(), create: vi.fn() }),
$fetch: Object.assign(vi.fn(), {
raw: vi.fn(),
native: vi.fn(),
create: vi.fn(),
}),
}));

const mockedFetch = vi.mocked($fetch);
Expand All @@ -26,6 +31,12 @@ const CLOUD_API_URL = "https://api.example.com";
const EMAIL = "noreply@example.com";
const RESPONSE_OK_STATUS = 200;
const RESPONSE_ERROR_STATUS = 500;
const CLOUD_RUN_HOST = "vease-abc123.europe-west1.run.app";

function setCloudUrlParam(value?: string): void {
const search = value === undefined ? "" : `?cloud_url=${encodeURIComponent(value)}`;
globalThis.history.replaceState(undefined, "", `/${search}`);
}

function setupConfig(): void {
const config = useRuntimeConfig();
Expand All @@ -49,7 +60,9 @@ function buildFetchResponse<ResponseData>(
responseData: ResponseData,
status: number,
): FetchResponse<ResponseData> {
const response: FetchResponse<ResponseData> = Response.json(responseData, { status });
const response: FetchResponse<ResponseData> = Response.json(responseData, {
status,
});
response._data = responseData;
return response;
}
Expand Down Expand Up @@ -121,7 +134,10 @@ describe("cloud store", () => {
const cloudStore = useCloudStore();
const feedbackStore = useFeedbackStore();

const error = createError({ statusCode: 500, statusMessage: "500 Internal Server Error" });
const error = createError({
statusCode: 500,
statusMessage: "500 Internal Server Error",
});

mockedFetch.mockImplementation((_route, options) => {
const onResponseErrorHooks = toHookArray(options?.onResponseError);
Expand All @@ -143,6 +159,130 @@ describe("cloud store", () => {
});
});

describe("connect_existing", () => {
beforeEach(() => {
mockedFetch.mockReset();
useInfraStore().app_mode = appMode.CLOUD;
});

test("successful connect_existing", async () => {
const cloudStore = useCloudStore();
const feedbackStore = useFeedbackStore();

mockedFetch.mockImplementation(async (_route, options) => {
const data = {};
const onResponseHooks = toHookArray(options?.onResponse);
await Promise.all(
onResponseHooks.map(async (hook) => {
await hook(buildFetchContext(data, RESPONSE_OK_STATUS));
}),
);
return data;
});

await cloudStore.connect_existing(CLOUD_RUN_HOST);

expect(mockedFetch).toHaveBeenCalledWith(
"opengeodeweb_back/ping",
expect.objectContaining({
baseURL: `https://${CLOUD_RUN_HOST}:443/geode`,
}),
);
expect(mockedFetch).not.toHaveBeenCalledWith("/cloud/run", expect.anything());
expect(cloudStore.status).toBe(Status.CONNECTED);
expect(feedbackStore.server_error).toBe(false);
expect(useInfraStore().domain_name).toBe(CLOUD_RUN_HOST);
});

test("failed connect_existing - unreachable service", async () => {
const cloudStore = useCloudStore();
const feedbackStore = useFeedbackStore();
const infraStore = useInfraStore();
const previous_domain_name = infraStore.domain_name;

const error = createError({
statusCode: 500,
statusMessage: "500 Internal Server Error",
});
mockedFetch.mockImplementation((_route, options) => {
const onResponseErrorHooks = toHookArray(options?.onResponseError);
for (const hook of onResponseErrorHooks) {
void hook(
buildFetchContext(
{ name: "Error", description: "500 Internal Server Error" },
RESPONSE_ERROR_STATUS,
),
);
}
throw error;
});

await expect(cloudStore.connect_existing(CLOUD_RUN_HOST)).rejects.toThrow(
"500 Internal Server Error",
);

expect(cloudStore.status).toBe(Status.NOT_CONNECTED);
expect(feedbackStore.server_error).toBe(true);
expect(infraStore.domain_name).toBe(previous_domain_name);
});
});

describe("start", () => {
beforeEach(() => {
mockedFetch.mockReset();
useInfraStore().app_mode = appMode.CLOUD;
mockedFetch.mockImplementation(async (_route, options) => {
const data = { url: "test.com" };
const onResponseHooks = toHookArray(options?.onResponse);
await Promise.all(
onResponseHooks.map(async (hook) => {
await hook(buildFetchContext(data, RESPONSE_OK_STATUS));
}),
);
return data;
});
});
afterEach(() => {
setCloudUrlParam();
});

test("without cloud_url launches a new service", async () => {
setupConfig();
setCloudUrlParam();
await useCloudStore().start(EMAIL);

expect(mockedFetch).toHaveBeenCalledWith("/cloud/run", expect.anything());
expect(useInfraStore().domain_name).toBe("test.com");
});

test("with cloud_url connects to the existing service", async () => {
setupConfig();
setCloudUrlParam(CLOUD_RUN_HOST);
await useCloudStore().start(EMAIL);

expect(mockedFetch).not.toHaveBeenCalledWith("/cloud/run", expect.anything());
expect(mockedFetch).toHaveBeenCalledWith("opengeodeweb_back/ping", expect.anything());
expect(useInfraStore().domain_name).toBe(CLOUD_RUN_HOST);
expect(globalThis.location.search).toBe("");
});

test("failed cloud_url is cleared so a retry launches a new service", async () => {
setupConfig();
setCloudUrlParam(CLOUD_RUN_HOST);
const cloudStore = useCloudStore();
mockedFetch.mockRejectedValueOnce(new Error("unreachable"));

await expect(cloudStore.start(EMAIL)).rejects.toThrow("unreachable");
expect(globalThis.location.search).toBe("");

mockedFetch.mockClear();
await cloudStore.start(EMAIL);
expect(mockedFetch).toHaveBeenCalledWith("/cloud/run", expect.anything());
expect(mockedFetch).not.toHaveBeenCalledWith("opengeodeweb_back/ping", expect.anything());
expect(useInfraStore().domain_name).toBe("test.com");
});
});

describe("connect", () => {
test("successful connect", () => {
const cloudStore = useCloudStore();
Expand Down
Loading
Loading