Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for external secondary instances #259

Merged
merged 27 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7ae25f3
scenario: correct porting mistake in repeat-based secondary instance …
eyelidlessness Nov 19, 2024
c57cb10
Set up shared access to form attachment fixtures
eyelidlessness Nov 21, 2024
ee8cb7f
Reuse simpler glob import generalization for XML XForms fixtures
eyelidlessness Nov 25, 2024
3d58132
Shared abstractions for jr resources
eyelidlessness Nov 21, 2024
4fa77ba
Former JR port ReferenceManagerTestUtils now references SharedJRResou…
eyelidlessness Nov 21, 2024
9b5c195
engine: separate configuration for retrieval of form definition, atta…
eyelidlessness Nov 21, 2024
b3f7b9c
scenario: support for JRResourceService to handle `fetchFormAttachments`
eyelidlessness Nov 25, 2024
7b59a0b
scenario: add failing tests for basic external secondary instance sup…
eyelidlessness Nov 25, 2024
75bc335
engine/client: expand `FetchResourceResponse` to include optional `he…
eyelidlessness Nov 25, 2024
0fbc3c5
engine: support for XML external secondary instances
eyelidlessness Nov 25, 2024
edf14f6
web-forms (Vue UI): auto setup JRResourceService to serve/load form a…
eyelidlessness Nov 25, 2024
e14ea93
engine: special case 404 response as a “blank” secondary instance
eyelidlessness Nov 26, 2024
2b65998
engine: support CSV external secondary instances
eyelidlessness Nov 26, 2024
d898240
scenario: remove now-impertinent notes on CSV not found test
eyelidlessness Nov 26, 2024
706aa50
scenario: remove now-impertinent notes about header-only CSV test
eyelidlessness Nov 26, 2024
4e68c48
engine: support for GeoJSON external secondary instances
eyelidlessness Nov 26, 2024
3e39af6
scenario: test exercising GeoJSON external secondary instances now pa…
eyelidlessness Nov 26, 2024
3b91a2c
scenario: test basic CSV external secondary instance support
eyelidlessness Nov 26, 2024
c0dc32c
fix: parsing CSV with trailing new lines
eyelidlessness Nov 26, 2024
2fddcec
changeset
eyelidlessness Nov 26, 2024
6b1f145
scenario: test details of CSV parsing…
eyelidlessness Nov 27, 2024
7222c30
engine: missing resource behavior error by default, blank by config
eyelidlessness Nov 27, 2024
4bcff2b
engine: client constant **types** are exported without the `constants…
eyelidlessness Nov 27, 2024
6f107bc
web-forms: partial support for previewing forms w/ external secondary…
eyelidlessness Nov 27, 2024
bd4a064
Use `JR_RESOURCE_URL_PROTOCOL` constant rather than same value inline
eyelidlessness Dec 11, 2024
d5ec9bd
Remove `fetchResource` config, update remaining references to more sp…
eyelidlessness Dec 11, 2024
128e15f
scenario: remove redundant tests with inline external secondary insta…
eyelidlessness Dec 11, 2024
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
8 changes: 8 additions & 0 deletions .changeset/seven-eels-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@getodk/xforms-engine': minor
'@getodk/web-forms': minor
'@getodk/scenario': minor
'@getodk/common': minor
---

Support for external secondary instances (XML, CSV, GeoJSON)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@types/eslint": "^9.6.1",
"@types/eslint-config-prettier": "^6.11.3",
"@types/eslint__js": "^8.42.3",
"@types/geojson": "^7946.0.14",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.7.2",
"@typescript-eslint/eslint-plugin": "^8.7.0",
Expand Down
60 changes: 60 additions & 0 deletions packages/common/src/fixtures/import-glob-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { IS_NODE_RUNTIME } from '../env/detection.ts';

interface GlobURLFetchResponse {
text(): Promise<string>;
}

type Awaitable<T> = Promise<T> | T;

type FetchGlobURL = (globURL: string) => Awaitable<GlobURLFetchResponse>;

let fetchGlobURL: FetchGlobURL;

if (IS_NODE_RUNTIME) {
const { readFile } = await import('node:fs/promises');

class NodeGlobURLFetchResponse {
readonly fsPath: string;

constructor(globURL: string) {
this.fsPath = globURL.replace('/@fs/', '/');
}

text(): Promise<string> {
return readFile(this.fsPath, 'utf-8');
}
}

fetchGlobURL = (globURL) => {
return new NodeGlobURLFetchResponse(globURL);
};
} else {
fetchGlobURL = fetch;
}

type ImportMetaGlobURLRecord = Readonly<Record<string, string>>;

export type ImportMetaGlobLoader = (this: void) => Promise<string>;

export type GlobLoaderEntry = readonly [absolutePath: string, loader: ImportMetaGlobLoader];

const globLoader = (globURL: string): ImportMetaGlobLoader => {
return async () => {
const response = await fetchGlobURL(globURL);

return response.text();
};
};

export const toGlobLoaderEntries = (
importMeta: ImportMeta,
globObject: ImportMetaGlobURLRecord
): readonly GlobLoaderEntry[] => {
const parentPathURL = new URL('./', importMeta.url);

return Object.entries(globObject).map(([relativePath, value]) => {
const { pathname: absolutePath } = new URL(relativePath, parentPathURL);

return [absolutePath, globLoader(value)];
});
};
3 changes: 3 additions & 0 deletions packages/common/src/fixtures/test-scenario/csv-attachment.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
item-label,item-value
Y,y
Z,z
10 changes: 10 additions & 0 deletions packages/common/src/fixtures/test-scenario/xml-attachment.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<instance-root>
<instance-item>
<item-label>A</item-label>
<item-value>a</item-value>
</instance-item>
<instance-item>
<item-label>B</item-label>
<item-value>b</item-value>
</instance-item>
</instance-root>
137 changes: 137 additions & 0 deletions packages/common/src/fixtures/xform-attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { UpsertableMap } from '../lib/collections/UpsertableMap.ts';
import { UnreachableError } from '../lib/error/UnreachableError.ts';
import type { ImportMetaGlobLoader } from './import-glob-helper.ts';
import { toGlobLoaderEntries } from './import-glob-helper.ts';

/**
* @todo Support Windows paths?
lognaturel marked this conversation as resolved.
Show resolved Hide resolved
*/
const getFileName = (absolutePath: string): string => {
const fileName = absolutePath.split('/').at(-1);

if (fileName == null) {
throw new Error(`Failed to get file name for file system path: ${absolutePath}`);
}

return fileName;
};

// prettier-ignore
const xformAttachmentFileExtensions = [
'.csv',
'.geojson',
'.xml',
'.xml.example',
lognaturel marked this conversation as resolved.
Show resolved Hide resolved
'.xlsx',
] as const;

type XFormAttachmentFileExtensions = typeof xformAttachmentFileExtensions;
type XFormAttachmentFileExtension = XFormAttachmentFileExtensions[number];

const getFileExtension = (absolutePath: string): XFormAttachmentFileExtension => {
for (const extension of xformAttachmentFileExtensions) {
if (absolutePath.endsWith(extension)) {
return extension;
}
}

throw new Error(`Unknown file extension for file name: ${getFileName(absolutePath)}`);
};

const getParentDirectory = (absolutePath: string): string => {
const fileName = getFileName(absolutePath);

return absolutePath.slice(0, absolutePath.length - fileName.length - 1);
};

const xformAttachmentFixtureLoaderEntries = toGlobLoaderEntries(
import.meta,
import.meta.glob<true, 'url', string>('./*/**/*', {
query: '?url',
import: 'default',
eager: true,
})
);

export class XFormAttachmentFixture {
readonly fileName: string;
readonly fileExtension: string;
readonly mimeType: string;

constructor(
readonly absolutePath: string,
readonly load: ImportMetaGlobLoader
) {
const fileName = getFileName(absolutePath);
const fileExtension = getFileExtension(fileName);

this.fileName = fileName;
this.fileExtension = fileExtension;

switch (fileExtension) {
case '.csv':
this.mimeType = 'text/csv';
break;

case '.geojson':
this.mimeType = 'application/geo+json';
break;

case '.xml':
case '.xml.example':
this.mimeType = 'text/xml';
break;

case '.xlsx':
this.mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
break;

default:
throw new UnreachableError(fileExtension);
}
}
}

type XFormAttachmentFixtureEntry = readonly [absolutePath: string, fixture: XFormAttachmentFixture];

type XFormAttachmentFixtureEntries = readonly XFormAttachmentFixtureEntry[];

const xformAttachmentFixtureEntries: XFormAttachmentFixtureEntries =
xformAttachmentFixtureLoaderEntries.map(([absolutePath, load]) => {
const fixture = new XFormAttachmentFixture(absolutePath, load);

return [absolutePath, fixture];
});

type XFormAttachmentFixturesByAbsolutePath = ReadonlyMap<string, XFormAttachmentFixture>;

const buildXFormAttachmentFixturesByAbsolutePath = (
entries: XFormAttachmentFixtureEntries
): XFormAttachmentFixturesByAbsolutePath => {
return new Map(entries);
};

export const xformAttachmentFixturesByPath = buildXFormAttachmentFixturesByAbsolutePath(
xformAttachmentFixtureEntries
);

type XFormAttachmentFixturesByDirectory = ReadonlyMap<string, readonly XFormAttachmentFixture[]>;

const buildXFormAttachmentFixturesByDirectory = (
entries: XFormAttachmentFixtureEntries
): XFormAttachmentFixturesByDirectory => {
const result = new UpsertableMap<string, XFormAttachmentFixture[]>();

for (const [absolutePath, fixture] of entries) {
const parentDirectory = getParentDirectory(absolutePath);
const subset = result.upsert(parentDirectory, () => []);

subset.push(fixture);
}

return result;
};

export const xformAttachmentFixturesByDirectory = buildXFormAttachmentFixturesByDirectory(
xformAttachmentFixtureEntries
);
80 changes: 53 additions & 27 deletions packages/common/src/fixtures/xforms.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,40 @@
import { IS_NODE_RUNTIME } from '../env/detection.ts';
import { JRResourceService } from '../jr-resources/JRResourceService.ts';
import type { JRResourceURL } from '../jr-resources/JRResourceURL.ts';
import { UpsertableMap } from '../lib/collections/UpsertableMap.ts';
import { toGlobLoaderEntries } from './import-glob-helper.ts';

type XFormResourceType = 'local' | 'remote';

type ResourceServiceFactory = () => JRResourceService;

interface BaseXFormResourceOptions {
readonly localPath: string | null;
readonly identifier: string | null;
readonly category: string | null;
readonly initializeFormAttachmentService?: ResourceServiceFactory;
}

interface LocalXFormResourceOptions extends BaseXFormResourceOptions {
readonly localPath: string;
readonly identifier: string;
readonly category: string;
readonly initializeFormAttachmentService: ResourceServiceFactory;
}

interface RemoteXFormResourceOptions extends BaseXFormResourceOptions {
readonly category: string | null;
readonly localPath: null;
readonly identifier: string;

/**
* @todo Note that {@link RemoteXFormResourceOptions} corresponds to an API
* primarily serving
* {@link https://getodk.org/web-forms-preview/ | Web Forms Preview}
* functionality. In theory, we could allow a mechanism to support form
* attachments in for that use case, but we'd need to design for it. Until
* then, it doesn't make a whole lot of sense to accept arbitrary IO here.
*/
readonly initializeFormAttachmentService?: never;
}

type XFormResourceOptions<Type extends XFormResourceType> = {
Expand Down Expand Up @@ -71,20 +87,28 @@ const xformURLLoader = (url: URL): LoadXFormXML => {
};
};

const getNoopResourceService: ResourceServiceFactory = () => {
return new JRResourceService();
};

export class XFormResource<Type extends XFormResourceType> {
static forLocalFixture(
importerURL: string,
relativePath: string,
localURL: URL | string,
localPath: string,
resourceURL: URL,
loadXML?: LoadXFormXML
): XFormResource<'local'> {
const resourceURL = new URL(localURL, importerURL);
const localPath = new URL(relativePath, importerURL).pathname;

return new XFormResource('local', resourceURL, loadXML ?? xformURLLoader(resourceURL), {
category: localFixtureDirectoryCategory(localPath),
localPath,
identifier: pathToFileName(localPath),
initializeFormAttachmentService: () => {
const service = new JRResourceService();
const parentPath = localPath.replace(/\/[^/]+$/, '');

service.activateFixtures(parentPath, ['file', 'file-csv']);

return service;
},
});
}

Expand All @@ -96,6 +120,8 @@ export class XFormResource<Type extends XFormResourceType> {
const loadXML = xformURLLoader(resourceURL);

return new XFormResource('remote', resourceURL, loadXML, {
...options,

category: options?.category ?? 'other',
identifier: options?.identifier ?? extractURLIdentifier(resourceURL),
localPath: options?.localPath ?? null,
Expand All @@ -105,6 +131,7 @@ export class XFormResource<Type extends XFormResourceType> {
readonly category: string;
readonly localPath: XFormResourceOptions<Type>['localPath'];
readonly identifier: XFormResourceOptions<Type>['identifier'];
readonly fetchFormAttachment: (url: JRResourceURL) => Promise<Response>;

private constructor(
readonly resourceType: Type,
Expand All @@ -115,37 +142,36 @@ export class XFormResource<Type extends XFormResourceType> {
this.category = options.category ?? 'other';
this.localPath = options.localPath;
this.identifier = options.identifier;
}
}

export type XFormFixture = XFormResource<'local'>;
const initializeFormAttachmentService =
options.initializeFormAttachmentService ?? getNoopResourceService;

const buildXFormFixtures = (): readonly XFormFixture[] => {
if (IS_NODE_RUNTIME) {
const fixtureXMLByRelativePath = import.meta.glob<false, 'raw', string>('./**/*.xml', {
query: '?raw',
import: 'default',
eager: false,
});
let resourceService: JRResourceService | null = null;

return Object.entries(fixtureXMLByRelativePath).map(([path, loadXML]) => {
const localURL = new URL(path, import.meta.url);
const fixture = XFormResource.forLocalFixture(import.meta.url, path, localURL, loadXML);
this.fetchFormAttachment = (url) => {
resourceService = resourceService ?? initializeFormAttachmentService();

return fixture;
});
return resourceService.handleRequest(url);
};
}
}

const fixtureURLByRelativePath = import.meta.glob<true, 'url', string>('./**/*.xml', {
const xformFixtureLoaderEntries = toGlobLoaderEntries(
import.meta,
import.meta.glob<true, 'url', string>('./**/*.xml', {
query: '?url',
import: 'default',
eager: true,
});
})
);

return Object.entries(fixtureURLByRelativePath).map(([path, url]) => {
const fixture = XFormResource.forLocalFixture(import.meta.url, path, url);
export type XFormFixture = XFormResource<'local'>;

const buildXFormFixtures = (): readonly XFormFixture[] => {
return xformFixtureLoaderEntries.map(([path, loadXML]) => {
const resourceURL = new URL(path, SELF_URL);

return fixture;
return XFormResource.forLocalFixture(path, resourceURL, loadXML);
});
};

Expand Down
Loading
Loading