From 7ae25f30e02f03231556c0518ad0e6bd26c1802a Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Tue, 19 Nov 2024 15:08:50 -0800 Subject: [PATCH 01/27] scenario: correct porting mistake in repeat-based secondary instance test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This isn’t directly related to coming work on this branch. It addresses a mistake in the original JavaRosa test porting effort. I caught it now as I was looking into existing coverage of external secondary instance functionality. --- .../scenario/test/secondary-instances.test.ts | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/scenario/test/secondary-instances.test.ts b/packages/scenario/test/secondary-instances.test.ts index 7a0c2079c..74d6bed3d 100644 --- a/packages/scenario/test/secondary-instances.test.ts +++ b/packages/scenario/test/secondary-instances.test.ts @@ -84,33 +84,25 @@ describe('Secondary instances', () => { * (`answerOf` return value) to be `equalTo(null)`. It seems likely * given the form's shape that the intent is to check that the field is * present and its value is blank, at that point in time. - * - * 3. (HUNCH ONLY!) I'm betting this failure is related to the form's - * `current()` sub-expression (which I doubt is being accounted for in - * dependency analysis, and is therefore failing to establish a - * reactive subscription within the engine). */ // JR: `doNotGetConfused` - it.fails( - "[re]computes separately within each respective repeat instance, when the predicate's dependencies affecting that node change", - async () => { - const scenario = await Scenario.init('repeat-secondary-instance.xml'); + it("[re]computes separately within each respective repeat instance, when the predicate's dependencies affecting that node change", async () => { + const scenario = await Scenario.init('repeat-secondary-instance.xml'); - scenario.createNewRepeat('/data/repeat'); - scenario.createNewRepeat('/data/repeat'); + scenario.createNewRepeat('/data/repeat'); + scenario.createNewRepeat('/data/repeat'); - scenario.answer('/data/repeat[1]/choice', 'a'); + scenario.answer('/data/repeat[1]/choice', 'a'); - expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe('A'); - // assertThat(scenario.answerOf('/data/repeat[2]/calculate'), equalTo(null)); - expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe(''); + expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe('A'); + // assertThat(scenario.answerOf('/data/repeat[2]/calculate'), equalTo(null)); + expect(scenario.answerOf('/data/repeat[2]/calculate').getValue()).toBe(''); - scenario.answer('/data/repeat[2]/choice', 'b'); + scenario.answer('/data/repeat[2]/choice', 'b'); - expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe('A'); - expect(scenario.answerOf('/data/repeat[2]/calculate').getValue()).toBe('B'); - } - ); + expect(scenario.answerOf('/data/repeat[1]/calculate').getValue()).toBe('A'); + expect(scenario.answerOf('/data/repeat[2]/calculate').getValue()).toBe('B'); + }); }); describe('predicates on different child names', () => { From c57cb103c6c2fd9d21a8d5ec3db225d3273bd4df Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 21 Nov 2024 09:10:25 -0800 Subject: [PATCH 02/27] Set up shared access to form attachment fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This evolves a similar pattern in the setup for XML XForms fixtures. It’s difficult to completely generalize glob imports, because the actual call to `import.meta.glob`, a Vite API, strictly requires inline literal values both for the glob pattern itself and for the options object. The generalization here allows use of a single call, which always produces a “URL”: - in browser runtimes, this is a URL which can be requested by `fetch` - on Node, it’s a path with a `/@fs/` prefix The same generalization will be applied to `xforms.ts` in a subsequent commit, simplifying that aspect of fixture loading there and reusing the same logic as much as possible. --- .../common/src/fixtures/import-glob-helper.ts | 60 ++++++++ .../common/src/fixtures/xform-attachments.ts | 137 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 packages/common/src/fixtures/import-glob-helper.ts create mode 100644 packages/common/src/fixtures/xform-attachments.ts diff --git a/packages/common/src/fixtures/import-glob-helper.ts b/packages/common/src/fixtures/import-glob-helper.ts new file mode 100644 index 000000000..e89553863 --- /dev/null +++ b/packages/common/src/fixtures/import-glob-helper.ts @@ -0,0 +1,60 @@ +import { IS_NODE_RUNTIME } from '../env/detection.ts'; + +interface GlobURLFetchResponse { + text(): Promise; +} + +type Awaitable = Promise | T; + +type FetchGlobURL = (globURL: string) => Awaitable; + +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 { + return readFile(this.fsPath, 'utf-8'); + } + } + + fetchGlobURL = (globURL) => { + return new NodeGlobURLFetchResponse(globURL); + }; +} else { + fetchGlobURL = fetch; +} + +type ImportMetaGlobURLRecord = Readonly>; + +export type ImportMetaGlobLoader = (this: void) => Promise; + +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)]; + }); +}; diff --git a/packages/common/src/fixtures/xform-attachments.ts b/packages/common/src/fixtures/xform-attachments.ts new file mode 100644 index 000000000..22d06ce27 --- /dev/null +++ b/packages/common/src/fixtures/xform-attachments.ts @@ -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? + */ +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', + '.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('./*/**/*', { + 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; + +const buildXFormAttachmentFixturesByAbsolutePath = ( + entries: XFormAttachmentFixtureEntries +): XFormAttachmentFixturesByAbsolutePath => { + return new Map(entries); +}; + +export const xformAttachmentFixturesByPath = buildXFormAttachmentFixturesByAbsolutePath( + xformAttachmentFixtureEntries +); + +type XFormAttachmentFixturesByDirectory = ReadonlyMap; + +const buildXFormAttachmentFixturesByDirectory = ( + entries: XFormAttachmentFixtureEntries +): XFormAttachmentFixturesByDirectory => { + const result = new UpsertableMap(); + + 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 +); From ee8cb7f31e4eb40018b6d09efed73ec5aca05757 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Mon, 25 Nov 2024 09:48:48 -0800 Subject: [PATCH 03/27] Reuse simpler glob import generalization for XML XForms fixtures --- packages/common/src/fixtures/xforms.ts | 44 ++++++++------------------ 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/packages/common/src/fixtures/xforms.ts b/packages/common/src/fixtures/xforms.ts index 558186049..75d140fab 100644 --- a/packages/common/src/fixtures/xforms.ts +++ b/packages/common/src/fixtures/xforms.ts @@ -1,5 +1,5 @@ -import { IS_NODE_RUNTIME } from '../env/detection.ts'; import { UpsertableMap } from '../lib/collections/UpsertableMap.ts'; +import { toGlobLoaderEntries } from './import-glob-helper.ts'; type XFormResourceType = 'local' | 'remote'; @@ -73,14 +73,10 @@ const xformURLLoader = (url: URL): LoadXFormXML => { export class XFormResource { 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, @@ -118,34 +114,22 @@ export class XFormResource { } } -export type XFormFixture = XFormResource<'local'>; - -const buildXFormFixtures = (): readonly XFormFixture[] => { - if (IS_NODE_RUNTIME) { - const fixtureXMLByRelativePath = import.meta.glob('./**/*.xml', { - query: '?raw', - import: 'default', - eager: false, - }); - - 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); - - return fixture; - }); - } - - const fixtureURLByRelativePath = import.meta.glob('./**/*.xml', { +const xformFixtureLoaderEntries = toGlobLoaderEntries( + import.meta, + import.meta.glob('./**/*.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); }); }; From 3d58132254f97e965f869fd83f020433dfdc7600 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 21 Nov 2024 12:09:05 -0800 Subject: [PATCH 04/27] Shared abstractions for jr resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **JRResource:** - Represents a resource resolvable for a given `jr:` URL - Provides a mechanism to load the resource’s data (currently just string data, we’ll likely expand this to support media attachments) - Provides a helper static factory method to create a `JRResource` instance from an `XFormAttachmentFixture` **JRResourceURL:** simple subclass of standard `URL` to ensure we’re working specifically with resources associated with `jr:` URLs. This will be helpful for internals as well as engine/client APIs. **JRResourceService:** - Provides an in-memory “service” (in the sense of a server) to activate and access `JRResource`s by URL. - Activation is designed to support: - Current test usage (as ported from JavaRosa) where a set of fixtures are made available from their directory path, under a set of categories (“schemes” in ported JR terminology) which are ultimately used to service URLs in a pattern like `jr://$category*` - Anticipated test usage, where we may activate certain fixtures _inline in a specific test_. Conceptually this will be similar to inline form definitions using the JR-ported XForm DSL. - Access is designed to support a fetch-like interface. More details on this approach in a later commit introducing the engine API for clients to provide access to form attachments. - - - These abstractions are shared because it’s anticipated they’ll be used in: - `@getodk/scenario` for test setup - `@getodk/web-forms` for dev/demo access to fixtures We could theoretically use the same interfaces to support form attachments in the Web Forms Preview as well, i.e. to allow users to upload form definitions and their associated attachments. But we’d probably want UI/UX design to accompany that. --- .../common/src/jr-resources/JRResource.ts | 39 +++++++ .../src/jr-resources/JRResourceService.ts | 108 ++++++++++++++++++ .../common/src/jr-resources/JRResourceURL.ts | 46 ++++++++ 3 files changed, 193 insertions(+) create mode 100644 packages/common/src/jr-resources/JRResource.ts create mode 100644 packages/common/src/jr-resources/JRResourceService.ts create mode 100644 packages/common/src/jr-resources/JRResourceURL.ts diff --git a/packages/common/src/jr-resources/JRResource.ts b/packages/common/src/jr-resources/JRResource.ts new file mode 100644 index 000000000..cd2c71936 --- /dev/null +++ b/packages/common/src/jr-resources/JRResource.ts @@ -0,0 +1,39 @@ +import type { XFormAttachmentFixture } from '../fixtures/xform-attachments.ts'; +import { JRResourceURL } from './JRResourceURL.ts'; + +type JRResourceLoader = (this: void) => Promise; + +export interface JRResourceSource { + readonly url: JRResourceURL; + readonly fileName: string; + readonly mimeType: string; + readonly load: JRResourceLoader; +} + +export class JRResource { + static fromFormAttachmentFixture(category: string, fixture: XFormAttachmentFixture): JRResource { + const { fileName, mimeType, load } = fixture; + const url = JRResourceURL.create(category, fileName); + + return new JRResource({ + url, + fileName, + mimeType, + load, + }); + } + + readonly url: JRResourceURL; + readonly fileName: string; + readonly mimeType: string; + readonly load: JRResourceLoader; + + constructor(source: JRResourceSource) { + const { url, fileName, mimeType, load } = source; + + this.url = url; + this.fileName = fileName; + this.mimeType = mimeType; + this.load = load; + } +} diff --git a/packages/common/src/jr-resources/JRResourceService.ts b/packages/common/src/jr-resources/JRResourceService.ts new file mode 100644 index 000000000..7297f9a73 --- /dev/null +++ b/packages/common/src/jr-resources/JRResourceService.ts @@ -0,0 +1,108 @@ +import { xformAttachmentFixturesByDirectory } from '../fixtures/xform-attachments.ts'; +import { JRResource } from './JRResource.ts'; +import type { JRResourceURLString } from './JRResourceURL.ts'; +import { JRResourceURL } from './JRResourceURL.ts'; + +export interface InlineFixtureMetadata { + readonly url: JRResourceURLString; + readonly fileName: string; + readonly mimeType: string; +} + +class JRResourceServiceRegistry extends Map {} + +interface ActivateFixturesOptions { + readonly suppressMissingFixturesDirectoryWarning?: boolean; +} + +export class JRResourceService { + readonly resources = new JRResourceServiceRegistry(); + + readonly handleRequest = async (url: JRResourceURL | JRResourceURLString): Promise => { + let resourceKey: JRResourceURLString; + + if (typeof url === 'string') { + resourceKey = url; + } else { + resourceKey = url.href; + } + + const resource = this.resources.get(resourceKey); + + if (resource == null) { + return new Response('Not found', { + status: 404, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + const { load, mimeType } = resource; + const body = await load(); + + return new Response(body, { + status: 200, + headers: { 'Content-Type': mimeType }, + }); + }; + + private setRegisteredResourceState(resource: JRResource) { + const url = resource.url.href; + + if (this.resources.has(url)) { + throw new Error(`Resource already registered for URL: ${url}`); + } + + this.resources.set(url, resource); + } + + activateFixtures( + fixtureDirectory: string, + categories: readonly string[], + options?: ActivateFixturesOptions + ): void { + this.reset(); + + try { + for (const category of categories) { + const fixtures = xformAttachmentFixturesByDirectory.get(fixtureDirectory); + + if (fixtures == null) { + if (options?.suppressMissingFixturesDirectoryWarning !== true) { + // eslint-disable-next-line no-console + console.warn(`No form attachments in directory: ${fixtureDirectory}`); + } + + continue; + } + + for (const fixture of fixtures) { + const resource = JRResource.fromFormAttachmentFixture(category, fixture); + + this.setRegisteredResourceState(resource); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error occurred during resource state setup:', error); + + this.reset(); + } + } + + activateResource(metadata: InlineFixtureMetadata, data: string): void { + const url = JRResourceURL.from(metadata.url); + const load = () => Promise.resolve(data); + const resource = new JRResource({ + url, + fileName: metadata.fileName, + mimeType: metadata.mimeType, + load, + }); + + this.setRegisteredResourceState(resource); + } + + reset(): void { + this.resources.clear(); + } +} diff --git a/packages/common/src/jr-resources/JRResourceURL.ts b/packages/common/src/jr-resources/JRResourceURL.ts new file mode 100644 index 000000000..b57383096 --- /dev/null +++ b/packages/common/src/jr-resources/JRResourceURL.ts @@ -0,0 +1,46 @@ +const JR_RESOURCE_URL_PROTOCOL = 'jr:'; +type JRResourceURLProtocol = typeof JR_RESOURCE_URL_PROTOCOL; + +export type JRResourceURLString = `${JRResourceURLProtocol}${string}`; + +interface ValidatedJRResourceURL extends URL { + readonly protocol: JRResourceURLProtocol; + readonly href: JRResourceURLString; +} + +type ValidateJRResourceURL = (url: URL) => asserts url is ValidatedJRResourceURL; + +const validateJRResourceURL: ValidateJRResourceURL = (url) => { + if (import.meta.env.DEV) { + const { protocol, href } = url; + + if (protocol !== JR_RESOURCE_URL_PROTOCOL || !href.startsWith(JR_RESOURCE_URL_PROTOCOL)) { + throw new Error(`Invalid JRResoruceURL: ${url}`); + } + } +}; + +export class JRResourceURL extends URL { + static create(category: string, fileName: string): JRResourceURL { + return new this(`jr://${category}/${fileName}`); + } + + static from(url: JRResourceURLString): JRResourceURL { + return new this(url); + } + + static isJRResourceReference(reference: string | null): reference is JRResourceURLString { + return reference?.startsWith('jr:') ?? false; + } + + declare readonly protocol: JRResourceURLProtocol; + declare readonly href: JRResourceURLString; + + private constructor(url: JRResourceURL); + private constructor(url: JRResourceURLString); + private constructor(url: URL | string) { + super(url); + + validateJRResourceURL(this); + } +} From 4fa77ba9ca97e5a5943c7fcac292516be00c9cdf Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 21 Nov 2024 13:18:03 -0800 Subject: [PATCH 05/27] Former JR port ReferenceManagerTestUtils now references SharedJRResourceService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **SharedJRResourceService:** - Subclass of `JRResourceService` which only allows initialization as a singleton (stored as module-private state) - Automatically resets its internal state before and after every test run **ReferenceManagerTestUtils:** this was previously a stub porting the same interface from JavaRosa. It is now updated to use the `SharedJRResourceService` singleton’s state. --- .../jr/reference/ReferenceManagerTestUtils.ts | 84 +++++++------------ .../src/resources/SharedJRResourceService.ts | 26 ++++++ 2 files changed, 54 insertions(+), 56 deletions(-) create mode 100644 packages/scenario/src/resources/SharedJRResourceService.ts diff --git a/packages/scenario/src/jr/reference/ReferenceManagerTestUtils.ts b/packages/scenario/src/jr/reference/ReferenceManagerTestUtils.ts index 1f93b4433..d04a06889 100644 --- a/packages/scenario/src/jr/reference/ReferenceManagerTestUtils.ts +++ b/packages/scenario/src/jr/reference/ReferenceManagerTestUtils.ts @@ -1,67 +1,39 @@ -import type { EngineConfig, InitializeFormOptions } from '@getodk/xforms-engine'; -import { afterEach, beforeEach } from 'vitest'; import type { JavaNIOPath } from '../../java/nio/Path.ts'; -import type { TextFileResourcePath } from '../file/TextFileResourcePath.ts'; - -/** - * @todo This is incomplete! It was intended to add preliminary support for - * resource loading consistent with setup in ported JavaRosa tests. Since we - * don't yet have any non-form resource loading logic, we'd have no way of - * exercising the actual functionality. As such, this is currently a sketch of - * what a basis for that might look like in terms of JavaRosa interface - * compatibility, test-scoped setup and teardown. When it does become relevant, - * it will likely intersect with {@link TextFileResourcePath} (or some other - * similar interface to resource fixtures) to service the pertinent resources as - * the engine requests them (via {@link EngineConfig}'s `fetchResource` option). - */ -class ResourceManager { - constructor( - readonly path: JavaNIOPath, - readonly jrResourceBasePaths: readonly string[] - ) {} -} - -let resourceManagers: ResourceManager[] = []; - -beforeEach(() => { - resourceManagers = []; -}); - -afterEach(() => { - resourceManagers = []; -}); +import { SharedJRResourceService } from '../../resources/SharedJRResourceService.ts'; /** * **PORTING NOTES** * - * The name {@link schemes} has been preserved in the signature of this function - * (corresponding to JavaRosa's static method of the same name). It somewhat - * unintuitively **does not** refer to a URL scheme (i.e. `jr:`), but rather the - * first path segment in a `jr:` resource template URL. For instance, if a form - * references a file resource `jr://file/external-data.geojson`, a test may set - * up access to that resource by calling this function and specifying `"files"` - * as a "scheme". - * - * - - - - * - * Exposed as a plain function; addresses pertinent aspects of the semantic - * intent of JavaRosa's same-named static method on the - * `ReferenceManagerTestUtils` class. + * This signature is preserved as a reference to the equivalent JavaRosa test + * setup interface. Some time was spent tracing the actual setup behavior, and + * it was determined (and since confirmed) that ultimately for test purposes the + * intent is to register a set of file system paths which are available for + * resolving fixtures and fixture resources. * - * Significant divergences: + * As such, the actual behavior when calling this function produces the + * following minimal equivalent behavior: * - * 1. Returns `void`, where JavaRosa's equivalent returns a `ReferenceManager` - * (which, per @lognaturel, "nobody likes [...] Don't look :smile:"). This - * appears to be safe for now, as there are no current references to its - * return value. + * 1. When called, any state produced by a prior call is reset. + * 2. The string representation of {@link path} establishes a common base file + * system path for all state produced by the current call. + * 3. For each value in {@link schemes} (naming preserved from JavaRosa), a file + * system path is produced by concatenating that as a subdirectory of that + * common base path. + * 4. Any logic in the active running test will serve fixture resources from the + * set of file system paths produced by the above steps. * - * 2. While also implicitly stateful, the intent is to keep that state scoped as - * clearly as possible to a given test (its state being tracked and cleaned - * up in an `afterEach` controlled locally in this module as well), and as - * minimal as possible to set up the web forms engine's closest semantic - * equivalent (the configuration of `config.fetchResource` in - * {@link InitializeFormOptions}). + * **Implicitly**, the same state is cleared before and after each test, to + * avoid establishing shared state between tests which might cause them to + * become dependent on ordering of test runs. */ export const setUpSimpleReferenceManager = (path: JavaNIOPath, ...schemes: string[]): void => { - resourceManagers.push(new ResourceManager(path, schemes)); + const service = SharedJRResourceService.init(); + + service.activateFixtures(path.toAbsolutePath().toString(), schemes, { + get suppressMissingFixturesDirectoryWarning(): boolean { + const stack = new Error().stack; + + return stack?.includes('configureReferenceManagerIncorrectly') ?? false; + }, + }); }; diff --git a/packages/scenario/src/resources/SharedJRResourceService.ts b/packages/scenario/src/resources/SharedJRResourceService.ts new file mode 100644 index 000000000..b39d3d035 --- /dev/null +++ b/packages/scenario/src/resources/SharedJRResourceService.ts @@ -0,0 +1,26 @@ +import { JRResourceService } from '@getodk/common/jr-resources/JRResourceService.ts'; +import { afterEach, beforeEach } from 'vitest'; + +let state: SharedJRResourceService | null = null; + +export class SharedJRResourceService extends JRResourceService { + static init(): SharedJRResourceService { + if (state == null) { + state = new this(); + } + + return state; + } + + private constructor() { + super(); + + beforeEach(() => { + this.reset(); + }); + + afterEach(() => { + this.reset(); + }); + } +} From 9b5c195227928f6ccc0e2f23a90f079170fe7152 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 21 Nov 2024 14:44:33 -0800 Subject: [PATCH 06/27] engine: separate configuration for retrieval of form definition, attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **IMPORTANT NOTES!** 1. This change deprecates the former, more general, `fetchResource` config. While conceptually suitable for the purpose it was originally designed (and for this API expansion), it seems likely to be a footgun in real world use. 2. This change **intentionally deviates** from the design direction which we previously settled in https://github.com/getodk/web-forms/issues/201. It is, instead, intended to be directly responsive to the discussion which followed that previously settled design. - - - **Why another `fetch`-like configuration?** Taking the above important notes in reverse order: _Intentional deviation_ The discussion in #201 ultimately convinced me that it would likely be a mistake to favor any Map-like interface for the purpose of resolving form attachments. There is too much ambiguity and baggage around the current mechanisms for _satisfying_ the interface. Ultimately, @brontolosone’s axiom—that a complete `jr:` URL is a particular form attachment’s identifier—**is correct**! Any truncation of them is a convenient shorthand which would align with known data sources. Paradoxically, in typical usage, clients ultimately **MUST** extrapolate those identifiers from more limited data: the source data itself will _only_ contain truncated shorthand values. We’d discussed supplying a parse-stage summary of the `jr:` URLs referenced by a form, allowing clients to populate a mapping before proceding with post-parse initialization. But the logic a client would implement to satisfy that mapping would be **identical** to the logic it would implement to satisfy a `fetch`-like function: given an expected `jr://foo/bar.ext`, and given a URL to an attachment named `bar.ext`, provide access to that URL. It seems more sensible to specify this API as a function rather than as a Map-like data structure, if for no other reason than to avoid introducing or further entrenching a particular shorthand interpretation. We will call a client’s `fetchFormAttachment` with each attachment’s complete identifier (the full `jr:` URL), and the client can satisfy that with whatever data it has available and whatever mapping logic it sees fit _at call time_, without codifying that shorthand into the engine’s logic. _Distinct `fetch`-like configurations_ A single `fetchResource` configuration would meet the minimum API requisite API surface area to retrieve form definitions _and their attachments_, but it would be considerably more error prone for clients to integrate: - `fetchResource` has up to this point been optional, and is typically ignored in existing client usage - a single `fetchResource` config would need to serve dual purposes: resolve a form definition by URL (which is likely to be a real, network-accessible URL) and resolve form attachments by identifier (which happens to be a valid URL, but will never resolve to a real network resource except if clients implement the same resolution logic with a Service Worker or other network IO-intercepting mechanism) - a client would need to determine the purpose of a given call _by inference_ (by inspecting the URL, and probably branching on a `jr:` prefix); the `fetch`-like API simply wouldn’t accommodate a more explicit distinction without **also overloading** HTTP semantics (e.g. by callling `fetchResource` with some special header) - - - It is also important to note that `fetchFormAttachment` is also optional, at least for now. The main reasons for this are detailed on the interface property’s JSDoc, but it also bears mentioning that this (along with keeping `fetchResource` as an alias to `fetchFormDefinition`) allows all currently supported functionality to keep working without any breaking changes. --- .../xforms-engine/src/client/EngineConfig.ts | 78 ++++++++++++++----- packages/xforms-engine/src/instance/index.ts | 12 ++- .../instance/internal-api/InstanceConfig.ts | 9 ++- .../test/instance/PrimaryInstance.test.ts | 3 +- 4 files changed, 75 insertions(+), 27 deletions(-) diff --git a/packages/xforms-engine/src/client/EngineConfig.ts b/packages/xforms-engine/src/client/EngineConfig.ts index c346a30e3..79e91e047 100644 --- a/packages/xforms-engine/src/client/EngineConfig.ts +++ b/packages/xforms-engine/src/client/EngineConfig.ts @@ -1,3 +1,5 @@ +import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; +import type { initializeForm } from '../instance/index.ts'; import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts'; /** @@ -29,7 +31,13 @@ export interface FetchResourceResponse { * actual resources (likely, but not necessarily, accessed at a corresponding * HTTP URL). */ -export type FetchResource = (resource: URL) => Promise; +export type FetchResource = ( + resource: Resource +) => Promise; + +export type FormAttachmentURL = JRResourceURL; + +export type FetchFormAttachment = FetchResource; /** * Options provided by a client to specify certain aspects of engine runtime @@ -55,29 +63,59 @@ export interface EngineConfig { readonly stateFactory?: OpaqueReactiveObjectFactory; /** - * A client may specify a generic function for retrieving resources referenced - * by a form, such as: * - * - Form definitions themselves (if not provided directly to the engine by - * the client) - * - External secondary instances - * - Media (images, audio, video, etc.) + * A client may specify an arbitrary {@link fetch}-like function for retrieving an XML XForm form + * definition. * - * The function is expected to be a subset of the - * {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API}, - * performing `GET` requests for: + * Notes: * - * - Text resources (e.g. XML, CSV, JSON/GeoJSON) - * - Binary resources (e.g. media) - * - Optionally streamed binary data of either (e.g. for optimized - * presentation of audio/video) + * - This configuration will only be consuled for calls to + * {@link initializeForm} with a URL referencing an XML XForm definition. It + * will be ignored for calls passing an XML XForm form definition directly. * - * If provided by a client, this function will be used by the engine to - * retrieve any such resource, as required for engine functionality. If - * absent, the engine will use the native `fetch` function (if available, a - * polyfill otherwise). Clients may use this function to provide resources - * from sources other than the network, (or even in a test client to provide - * e.g. resources from test fixtures). + * - For calls to {@link initializeForm} with a URL, if this configuration is + * not specified it will default to the global {@link fetch} function (if + * one is defined). + */ + readonly fetchFormDefinition?: FetchResource; + + /** + * @deprecated + * @alias fetchFormDefinition */ readonly fetchResource?: FetchResource; + + /** + * A client may specify an arbitrary {@link fetch}-like function to retrieve a + * form's attachments, i.e. any `jr:` URL referenced by the form (as specified + * by {@link https://getodk.github.io/xforms-spec/ | ODK XForms}). + * + * Notes: + * + * - This configuration will be consulted for all supported form attachments, + * as a part of {@link initializeForm | form initialization}. + * + * - If this configuration is not specified it will default to the global + * {@link fetch} function (if one is defined). + * + * This default behavior will typically result in failure to load form + * attachments—and in most cases this will also cause + * {@link initializeForm | form initialization} to fail overall—with the + * following exceptions: + * + * - **CLIENT-SPECIFIC:** Usage in coordination with a client-implemented + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API | Serivce Worker}, + * which can intercept network requests **generally**. Clients already using + * a Service Worker may opt for the convenience of handling network requests + * for `jr:` URLs along with any other network interception logic. Client + * implementors should be warned, however, that such `jr:` URLs are not + * namespaced or otherwise scoped to a particular form; such a client would + * therefore inherently need to coordinate state between the Service Worker + * and the main thread (or whatever other realm calls + * {@link initializeForm}). + * + * - **PENDING:** Any usage where the engine does not require access to a + * form's attachments. + */ + readonly fetchFormAttachment?: FetchFormAttachment; } diff --git a/packages/xforms-engine/src/instance/index.ts b/packages/xforms-engine/src/instance/index.ts index fc34e6c9e..5ccff2c17 100644 --- a/packages/xforms-engine/src/instance/index.ts +++ b/packages/xforms-engine/src/instance/index.ts @@ -1,5 +1,6 @@ import { identity } from '@getodk/common/lib/identity.ts'; import { getOwner } from 'solid-js'; +import type { EngineConfig } from '../client/EngineConfig.ts'; import type { RootNode } from '../client/RootNode.ts'; import type { InitializeFormOptions as BaseInitializeFormOptions, @@ -17,10 +18,11 @@ interface InitializeFormOptions extends BaseInitializeFormOptions { readonly config: Partial; } -const buildInstanceConfig = (options: Partial = {}): InstanceConfig => { +const buildInstanceConfig = (options: EngineConfig = {}): InstanceConfig => { return { - createUniqueId: options.createUniqueId ?? createUniqueId, - fetchResource: options.fetchResource ?? fetch, + createUniqueId, + fetchFormDefinition: options.fetchFormDefinition ?? options.fetchResource ?? fetch, + fetchFormAttachment: options.fetchFormAttachment ?? fetch, stateFactory: options.stateFactory ?? identity, }; }; @@ -32,7 +34,9 @@ export const initializeForm = async ( const owner = getOwner(); const scope = createReactiveScope({ owner }); const engineConfig = buildInstanceConfig(options.config); - const sourceXML = await retrieveSourceXMLResource(input, engineConfig); + const sourceXML = await retrieveSourceXMLResource(input, { + fetchResource: engineConfig.fetchFormDefinition, + }); const form = new XFormDefinition(sourceXML); const primaryInstance = new PrimaryInstance(scope, form.model, engineConfig); diff --git a/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts b/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts index 36f20305d..3609f25a4 100644 --- a/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts +++ b/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts @@ -1,7 +1,12 @@ -import type { EngineConfig } from '../../client/EngineConfig.ts'; +import type { FetchFormAttachment, FetchResource } from '../../client/EngineConfig.ts'; +import type { OpaqueReactiveObjectFactory } from '../../client/OpaqueReactiveObjectFactory.ts'; import type { CreateUniqueId } from '../../lib/unique-id.ts'; -export interface InstanceConfig extends Required { +export interface InstanceConfig { + readonly stateFactory: OpaqueReactiveObjectFactory; + readonly fetchFormDefinition: FetchResource; + readonly fetchFormAttachment: FetchFormAttachment; + /** * Uniqueness per form instance session (so e.g. persistence isn't necessary). */ diff --git a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts index bedad4400..4087d3ce7 100644 --- a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts +++ b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts @@ -77,7 +77,8 @@ describe('PrimaryInstance engine representation of instance state', () => { return scope.runTask(() => { return new PrimaryInstance(scope, xformDefinition.model, { createUniqueId, - fetchResource, + fetchFormAttachment: fetchResource, + fetchFormDefinition: fetchResource, stateFactory, }); }); From b3f7b9c34807951558f6dfdabaf391a0f37d5bc6 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Mon, 25 Nov 2024 13:34:14 -0800 Subject: [PATCH 07/27] scenario: support for JRResourceService to handle `fetchFormAttachments` --- packages/scenario/src/client/init.ts | 3 +++ packages/scenario/src/jr/Scenario.ts | 25 +++++++++++-------- .../scenario/src/reactive/ReactiveScenario.ts | 6 ++--- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/scenario/src/client/init.ts b/packages/scenario/src/client/init.ts index 03666f3e7..9221ab724 100644 --- a/packages/scenario/src/client/init.ts +++ b/packages/scenario/src/client/init.ts @@ -1,3 +1,4 @@ +import type { JRResourceService } from '@getodk/common/jr-resources/JRResourceService.ts'; import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts'; import type { EngineConfig, @@ -53,6 +54,7 @@ const fetchResourceStub: typeof fetch = () => { }; export interface InitializeTestFormOptions { + readonly resourceService: JRResourceService; readonly stateFactory: OpaqueReactiveObjectFactory; } @@ -82,6 +84,7 @@ export const initializeTestForm = async ( return initializeForm(formResource, { config: { ...defaultConfig, + fetchFormAttachment: options.resourceService.handleRequest, stateFactory: options.stateFactory, }, }); diff --git a/packages/scenario/src/jr/Scenario.ts b/packages/scenario/src/jr/Scenario.ts index 4cc3b9613..b779f1c27 100644 --- a/packages/scenario/src/jr/Scenario.ts +++ b/packages/scenario/src/jr/Scenario.ts @@ -24,6 +24,7 @@ import { getClosestRepeatRange, getNodeForReference } from '../client/traversal. import { ImplementationPendingError } from '../error/ImplementationPendingError.ts'; import { UnclearApplicabilityError } from '../error/UnclearApplicabilityError.ts'; import type { ReactiveScenario } from '../reactive/ReactiveScenario.ts'; +import { SharedJRResourceService } from '../resources/SharedJRResourceService.ts'; import type { JRFormEntryCaption } from './caption/JRFormEntryCaption.ts'; import type { BeginningOfFormEvent } from './event/BeginningOfFormEvent.ts'; import type { EndOfFormEvent } from './event/EndOfFormEvent.ts'; @@ -85,9 +86,8 @@ const isFormFileName = (value: FormDefinitionResource | string): value is FormFi // prettier-ignore type ScenarioStaticInitParameters = - | readonly [formFileName: FormFileName] - | readonly [formName: string, form: XFormsElement] + | readonly [formName: string, form: XFormsElement, overrideOptions?: Partial] | readonly [resource: FormDefinitionResource]; interface AssertCurrentReferenceOptions { @@ -150,9 +150,14 @@ export class Scenario { /** * To be overridden, e.g. by {@link ReactiveScenario}. */ - // eslint-disable-next-line @typescript-eslint/class-literal-property-style - static get initializeTestFormOptions(): InitializeTestFormOptions | null { - return null; + + static getInitializeTestFormOptions( + overrideOptions?: Partial + ): InitializeTestFormOptions { + return { + resourceService: overrideOptions?.resourceService ?? SharedJRResourceService.init(), + stateFactory: overrideOptions?.stateFactory ?? nonReactiveIdentityStateFactory, + }; } static async init( @@ -161,6 +166,7 @@ export class Scenario { ): Promise { let resource: TestFormResource; let formName: string; + let options: InitializeTestFormOptions; if (isFormFileName(args[0])) { return this.init(r(args[0])); @@ -168,18 +174,15 @@ export class Scenario { const [pathResource] = args; resource = pathResource; formName = pathResource.formName; + options = this.getInitializeTestFormOptions(); } else { - const [name, form] = args; + const [name, form, overrideOptions] = args; formName = name; resource = form; + options = this.getInitializeTestFormOptions(overrideOptions); } - const options: InitializeTestFormOptions = { - stateFactory: nonReactiveIdentityStateFactory, - ...this.initializeTestFormOptions, - }; - const { dispose, owner, instanceRoot } = await initializeTestForm(resource, options); return runWithOwner(owner, () => { diff --git a/packages/scenario/src/reactive/ReactiveScenario.ts b/packages/scenario/src/reactive/ReactiveScenario.ts index dd724094e..4765d6c27 100644 --- a/packages/scenario/src/reactive/ReactiveScenario.ts +++ b/packages/scenario/src/reactive/ReactiveScenario.ts @@ -6,10 +6,10 @@ import type { InitializeTestFormOptions } from '../client/init.ts'; import { Scenario, type ScenarioConstructorOptions } from '../jr/Scenario.ts'; export class ReactiveScenario extends Scenario { - static override get initializeTestFormOptions(): InitializeTestFormOptions { - return { + static override getInitializeTestFormOptions(): InitializeTestFormOptions { + return super.getInitializeTestFormOptions({ stateFactory: createMutable, - }; + }); } private readonly testScopedOwner: Owner; From 7b59a0b3fc9ccc2367efb5817bb5d30223c5587a Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Mon, 25 Nov 2024 13:38:52 -0800 Subject: [PATCH 08/27] scenario: add failing tests for basic external secondary instance support (XML) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I wasn’t able to find any tests this basic/of XML support specifically. Both tests utilize the previous commit’s additive support for specifying a (local) `JRResourceService` to handle form attachments. Both tests exercise the same _logic_. As noted in the comment above the second test, the substantive difference is that the attachment fixture is defined inline in the test, similarly to how we use the JavaRosa XForm DSL to define form definitions themselves. --- .../fixtures/test-scenario/xml-attachment.xml | 10 +++ .../scenario/test/secondary-instances.test.ts | 86 ++++++++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 packages/common/src/fixtures/test-scenario/xml-attachment.xml diff --git a/packages/common/src/fixtures/test-scenario/xml-attachment.xml b/packages/common/src/fixtures/test-scenario/xml-attachment.xml new file mode 100644 index 000000000..517a46b9b --- /dev/null +++ b/packages/common/src/fixtures/test-scenario/xml-attachment.xml @@ -0,0 +1,10 @@ + + + A + a + + + B + b + + diff --git a/packages/scenario/test/secondary-instances.test.ts b/packages/scenario/test/secondary-instances.test.ts index 74d6bed3d..8b3634db6 100644 --- a/packages/scenario/test/secondary-instances.test.ts +++ b/packages/scenario/test/secondary-instances.test.ts @@ -1,3 +1,4 @@ +import { JRResourceService } from '@getodk/common/jr-resources/JRResourceService.ts'; import { bind, body, @@ -14,7 +15,8 @@ import { t, title, } from '@getodk/common/test/fixtures/xform-dsl/index.ts'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { stringAnswer } from '../src/answer/ExpectedStringAnswer.ts'; import { Scenario } from '../src/jr/Scenario.ts'; import { setUpSimpleReferenceManager } from '../src/jr/reference/ReferenceManagerTestUtils.ts'; import { r } from '../src/jr/resource/ResourcePathHelper.ts'; @@ -703,4 +705,86 @@ describe('Secondary instances', () => { }); }); }); + + describe('basic external secondary instance support', () => { + const attachmentFileName = 'xml-attachment.xml'; + const attachmentURL = `jr://file/${attachmentFileName}` as const; + const formTitle = 'External secondary instance (XML)'; + const formDefinition = html( + head( + title(formTitle), + model( + mainInstance(t('data id="external-secondary-instance-xml"', t('first'))), + + t(`instance id="external-xml" src="${attachmentURL}"`), + + bind('/data/first').type('string') + ) + ), + body( + // Define a select using value and label references that don't exist in the secondary instance + select1Dynamic( + '/data/first', + "instance('external-xml')/instance-root/instance-item", + 'item-value', + 'item-label' + ) + ) + ); + + let resourceService: JRResourceService; + + beforeEach(() => { + resourceService = new JRResourceService(); + }); + + afterEach(() => { + resourceService.reset(); + }); + + it('supports external secondary instances (XML, file system fixture)', async () => { + resourceService.activateFixtures( + r(attachmentFileName).toAbsolutePath().getParent().toString(), + ['file'] + ); + + const scenario = await Scenario.init(formTitle, formDefinition, { + resourceService, + }); + + scenario.answer('/data/first', 'a'); + + expect(scenario.answerOf('/data/first')).toEqualAnswer(stringAnswer('a')); + }); + + // This test is redundant to the one above, but demonstrates how we could + // define form attachments inline, like we do form definitions—a pattern + // worth considering if we want to expand external secondary instance + // support and/or test coverage. + it('supports external secondary instances (XML, file inline fixture)', async () => { + resourceService.activateResource( + { + url: attachmentURL, + fileName: attachmentFileName, + mimeType: 'text/xml', + }, + // prettier-ignore + t('instance-root', + t('instance-item', + t('item-label', 'A'), + t('item-value', 'a')), + t('instance-item', + t('item-label', 'B'), + t('item-value', 'b'))).asXml() + ); + + const scenario = await Scenario.init(formTitle, formDefinition, { + resourceService, + }); + + scenario.answer('/data/first', 'a'); + + expect(scenario.answerOf('/data/first')).toEqualAnswer(stringAnswer('a')); + }); + }); }); From 75bc33540de2abb4a78a93707e221f78c7478452 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Mon, 25 Nov 2024 13:42:16 -0800 Subject: [PATCH 09/27] engine/client: expand `FetchResourceResponse` to include optional `headers` This moves the existing resource-related types into a dedicated module (they probably already should have been!). It also revises their JSDoc to reflect some of the semantic expectations clarified in https://github.com/getodk/web-forms/issues/201. --- .../xforms-engine/src/client/EngineConfig.ts | 39 +----- .../xforms-engine/src/client/resources.ts | 118 ++++++++++++++++++ packages/xforms-engine/src/index.ts | 1 + .../instance/internal-api/InstanceConfig.ts | 2 +- .../xforms-engine/src/instance/resource.ts | 2 +- .../test/instance/PrimaryInstance.test.ts | 2 +- 6 files changed, 123 insertions(+), 41 deletions(-) create mode 100644 packages/xforms-engine/src/client/resources.ts diff --git a/packages/xforms-engine/src/client/EngineConfig.ts b/packages/xforms-engine/src/client/EngineConfig.ts index 79e91e047..68c8c86a6 100644 --- a/packages/xforms-engine/src/client/EngineConfig.ts +++ b/packages/xforms-engine/src/client/EngineConfig.ts @@ -1,43 +1,6 @@ -import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; import type { initializeForm } from '../instance/index.ts'; import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts'; - -/** - * @todo this is currently a strict subset of the web standard `Response`. Is it - * sufficient? Ways it might not be: - * - * - No way to convey metadata about the resource - * - Ambiguous if a client supplies an alternative implementation which doesn't - * exhaust the body on access - */ -export interface FetchResourceResponse { - readonly ok?: boolean; - readonly body?: ReadableStream | null; - readonly bodyUsed?: boolean; - - readonly blob: () => Promise; - readonly text: () => Promise; -} - -/** - * @todo this is a strict subset of the web standard `fetch` interface. It - * implicitly assumes that the engine itself will only ever issue `GET`-like - * requests. It also provides no further request-like semantics to the engine. - * This is presumed sufficient for now, but notably doesn't expose any notion of - * content negotiation (e.g. the ability to supply `Accept` headers). - * - * This also completely ignores any notion of mapping - * {@link https://getodk.github.io/xforms-spec/#uris | `jr:` URLs} to their - * actual resources (likely, but not necessarily, accessed at a corresponding - * HTTP URL). - */ -export type FetchResource = ( - resource: Resource -) => Promise; - -export type FormAttachmentURL = JRResourceURL; - -export type FetchFormAttachment = FetchResource; +import type { FetchFormAttachment, FetchResource } from './resources.ts'; /** * Options provided by a client to specify certain aspects of engine runtime diff --git a/packages/xforms-engine/src/client/resources.ts b/packages/xforms-engine/src/client/resources.ts new file mode 100644 index 000000000..288cb7ead --- /dev/null +++ b/packages/xforms-engine/src/client/resources.ts @@ -0,0 +1,118 @@ +import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; +import type { initializeForm } from '../instance/index.ts'; + +interface FetchResourceHeadersIterator + extends IteratorObject< + T, + // Note: we use this weird TypeScript intrinsic type so a built-in + // `HeadersIterator` is assignable regardless of a client's configured + // TypeScript or linting strictness. We don't actually care about the type, or + // consume the value it represents. + BuiltinIteratorReturn, + unknown + > { + [Symbol.iterator](): FetchResourceHeadersIterator; +} + +type FetchResourceHeadersForEachCallbackFn = ( + value: string, + key: string, + parent: FetchResourceResponseHeaders +) => void; + +/** + * A read-only strict subset of the web standard {@link Headers}. + * + * Note that the engine may make the following assumptions about + * {@link FetchResourceResponse.headers}: + * + * - If {@link FetchResourceResponse} is an instance of {@link Response}, it + * will be assumed its {@link FetchResourceResponse.headers | headers object} + * _is present_, and that it is an instance of {@link Headers}. In other + * words: for the purposes of resource resolution, we explicitly expect that + * clients using APIs provided by the runtime platform (or polyfills thereof) + * will not monkey-patch properties of values produced by those APIs. + * + * - If the object is an instance of {@link Headers} (whether by inference as a + * property of {@link Response}, or by a direct instance type check), the + * engine will assume it is safe to treat header names as case insensitive for + * any lookups it may perform. In other words: we explicitly expect that + * clients _providing access_ to APIs rovided by the runtime platform (or + * polyfills thereof) will not alter the guarantees of those APIs. + * + * - If the object is not an instance of {@link Headers}, it will be treated as + * a {@link ReadonlyMap}. In other words: we explicitly expect + * that clients, when providing a bespoke implementation of + * {@link FetchResourceResponse} and its constituent parts, will likely + * implement them partially (and in the case of + * {@link FetchResourceResponse.headers}, with the nearest common idiom + * available). In this case, we will favor a best effort at correctness, + * generally at some expense of performance. + */ +export interface FetchResourceResponseHeaders { + [Symbol.iterator](): FetchResourceHeadersIterator<[string, string]>; + + entries(): FetchResourceHeadersIterator<[string, string]>; + keys(): FetchResourceHeadersIterator; + values(): FetchResourceHeadersIterator; + + get(name: string): string | null; + has(name: string): boolean; + forEach(callbackfn: FetchResourceHeadersForEachCallbackFn): void; +} + +/** + * This is a strict subset of the web standard {@link Response}. Clients are + * encouraged to use the global {@link Response} constructor (as provided by the + * runtime platform, or by a global runtime polyfill), but may also provide a + * bespoke implementation if it suits their needs. + * + * Since we cannot assume a client's implementation will always be an instance + * of {@link Response}, we make some assumptions about its {@link headers} + * object (if available, as detailed on {@link FetchResourceResponseHeaders}). + * + * For other properties, we make the following assumptions (all of which are + * assumptions we would make about a platform-provided/polyfilled + * {@link Response}, but are explicitly stated for the benefit of confidence in + * client implementations): + * + * - If we read {@link body} directly, we will assume it is consumed on first + * read, and will not read it again. + * + * - We assume that {@link blob} and {@link text} indirectly consume + * {@link body} on first read as well, and will only ever read one of each of + * these properties, and only ever once. + * + * Furthermore, if the engine intends to read {@link body} (or its indirect + * {@link blob} or {@link text} consumers), it will do so in the course of a + * client's call to {@link initializeForm}, and before the + * {@link Promise} returned by that call is resolved. + */ +export interface FetchResourceResponse { + readonly ok?: boolean; + readonly status?: number; + readonly body?: ReadableStream | null; + readonly bodyUsed?: boolean; + readonly headers?: FetchResourceResponseHeaders; + + readonly blob: () => Promise; + readonly text: () => Promise; +} + +/** + * This is a strict subset of the web standard `fetch` interface. It implicitly + * assumes that the engine itself will only ever perform `GET`-like network/IO + * requests. It also provides no further request-like semantics to the engine. + * + * This is presumed sufficient for now, but notably doesn't expose any notion of + * content negotiation (e.g. the ability for the engine to include `Accept` + * headers in resource requests issued to a client's {@link FetchResource} + * implementation). + */ +export type FetchResource = ( + resource: Resource +) => Promise; + +export type FormAttachmentURL = JRResourceURL; + +export type FetchFormAttachment = FetchResource; diff --git a/packages/xforms-engine/src/index.ts b/packages/xforms-engine/src/index.ts index 767ba29e6..7b9dffdc3 100644 --- a/packages/xforms-engine/src/index.ts +++ b/packages/xforms-engine/src/index.ts @@ -25,6 +25,7 @@ export type * from './client/OpaqueReactiveObjectFactory.ts'; export type * from './client/repeat/RepeatInstanceNode.ts'; export type * from './client/repeat/RepeatRangeControlledNode.ts'; export type * from './client/repeat/RepeatRangeUncontrolledNode.ts'; +export type * from './client/resources.ts'; export type * from './client/RootNode.ts'; export type * from './client/SelectNode.ts'; export type * from './client/StringNode.ts'; diff --git a/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts b/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts index 3609f25a4..d0a2951c5 100644 --- a/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts +++ b/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts @@ -1,5 +1,5 @@ -import type { FetchFormAttachment, FetchResource } from '../../client/EngineConfig.ts'; import type { OpaqueReactiveObjectFactory } from '../../client/OpaqueReactiveObjectFactory.ts'; +import type { FetchFormAttachment, FetchResource } from '../../client/resources.ts'; import type { CreateUniqueId } from '../../lib/unique-id.ts'; export interface InstanceConfig { diff --git a/packages/xforms-engine/src/instance/resource.ts b/packages/xforms-engine/src/instance/resource.ts index 3c81bc997..89c2f04f6 100644 --- a/packages/xforms-engine/src/instance/resource.ts +++ b/packages/xforms-engine/src/instance/resource.ts @@ -1,7 +1,7 @@ import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts'; -import type { FetchResource, FetchResourceResponse } from '../client/EngineConfig.ts'; import type { FormResource } from '../client/index.ts'; +import type { FetchResource, FetchResourceResponse } from '../client/resources.ts'; export type { FetchResource, FetchResourceResponse, FormResource }; diff --git a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts index 4087d3ce7..1d16d8b76 100644 --- a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts +++ b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts @@ -11,10 +11,10 @@ import { title, } from '@getodk/common/test/fixtures/xform-dsl'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { FetchResource } from '../../src/client/EngineConfig.ts'; import type { ActiveLanguage } from '../../src/client/FormLanguage.ts'; import type { OpaqueReactiveObjectFactory } from '../../src/client/OpaqueReactiveObjectFactory.ts'; import type { RootNode } from '../../src/client/RootNode.ts'; +import type { FetchResource } from '../../src/client/resources.ts'; import { PrimaryInstance } from '../../src/instance/PrimaryInstance.ts'; import { Root } from '../../src/instance/Root.ts'; import { InstanceNode } from '../../src/instance/abstract/InstanceNode.ts'; From 0fbc3c538f587d3234972e2ca53d299599b7e2fc Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Mon, 25 Nov 2024 13:54:39 -0800 Subject: [PATCH 10/27] engine: support for XML external secondary instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This also lays most of the groundwork for supporting CSV and GeoJSON formats, except for the actual parsing of those formats into `SecondaryInstanceDefinition`s. The intent of this internal design is to break up parsing into these staged layers: 1. I/O 2. Data format detection 3. Per-format parsing logic 4. Common representation of all **external** secondary instances 5. Finally, common representation of **all** secondary instances - - - There’s a bit of churn at some of the interface boundaries _within the engine_, but they’re all entirely internal so this doesn’t represent a breaking change to clients. Observation: some of the `XFormDefinition` boundaries are starting to feel a little creaky! - - - This also introduces a base abstraction for reprsenting form attachments **generally**, which I believe will be suitable for expanding support to media. (I locally explored expanding that further to support generating a local `blob:` URL for those media, to ensure we respect the `fetch`-like semantic guarantees for client-configured I/O. That’s out of scope for this work, but I think it’s well positioned to handle that when we’re ready.) --- .../src/instance/PrimaryInstance.ts | 10 +- packages/xforms-engine/src/instance/index.ts | 10 +- .../src/parse/XFormDefinition.ts | 7 +- .../attachments/FormAttachmentResource.ts | 40 +++++ .../src/parse/model/ModelDefinition.ts | 3 - .../SecondaryInstancesDefinition.ts | 90 ++++++++-- .../sources/CSVExternalSecondaryInstance.ts | 9 + .../ExternalSecondaryInstanceResource.ts | 167 ++++++++++++++++++ .../ExternalSecondaryInstanceSource.ts | 22 +++ .../GeoJSONExternalSecondaryInstance.ts | 11 ++ .../InternalSecondaryInstanceSource.ts | 19 ++ .../sources/SecondaryInstanceSource.ts | 27 +++ .../XMLExternalSecondaryInstanceSource.ts | 32 ++++ .../test/instance/PrimaryInstance.test.ts | 10 +- .../test/parse/XFormDefinition.test.ts | 5 +- .../test/parse/body/BodyDefinition.test.ts | 5 +- .../test/parse/model/BindDefinition.test.ts | 7 +- .../test/parse/model/ModelBindMap.test.ts | 4 +- .../test/parse/model/ModelDefinition.test.ts | 14 +- 19 files changed, 456 insertions(+), 36 deletions(-) create mode 100644 packages/xforms-engine/src/parse/attachments/FormAttachmentResource.ts create mode 100644 packages/xforms-engine/src/parse/model/SecondaryInstance/sources/CSVExternalSecondaryInstance.ts create mode 100644 packages/xforms-engine/src/parse/model/SecondaryInstance/sources/ExternalSecondaryInstanceResource.ts create mode 100644 packages/xforms-engine/src/parse/model/SecondaryInstance/sources/ExternalSecondaryInstanceSource.ts create mode 100644 packages/xforms-engine/src/parse/model/SecondaryInstance/sources/GeoJSONExternalSecondaryInstance.ts create mode 100644 packages/xforms-engine/src/parse/model/SecondaryInstance/sources/InternalSecondaryInstanceSource.ts create mode 100644 packages/xforms-engine/src/parse/model/SecondaryInstance/sources/SecondaryInstanceSource.ts create mode 100644 packages/xforms-engine/src/parse/model/SecondaryInstance/sources/XMLExternalSecondaryInstanceSource.ts diff --git a/packages/xforms-engine/src/instance/PrimaryInstance.ts b/packages/xforms-engine/src/instance/PrimaryInstance.ts index 0d361fb35..f416296c4 100644 --- a/packages/xforms-engine/src/instance/PrimaryInstance.ts +++ b/packages/xforms-engine/src/instance/PrimaryInstance.ts @@ -28,6 +28,7 @@ import type { SimpleAtomicStateSetter } from '../lib/reactivity/types.ts'; import type { BodyClassList } from '../parse/body/BodyDefinition.ts'; import type { ModelDefinition } from '../parse/model/ModelDefinition.ts'; import type { RootDefinition } from '../parse/model/RootDefinition.ts'; +import type { SecondaryInstancesDefinition } from '../parse/model/SecondaryInstance/SecondaryInstancesDefinition.ts'; import { InstanceNode } from './abstract/InstanceNode.ts'; import type { EvaluationContext } from './internal-api/EvaluationContext.ts'; import type { InstanceConfig } from './internal-api/InstanceConfig.ts'; @@ -116,7 +117,12 @@ export class PrimaryInstance readonly evaluator: EngineXPathEvaluator; override readonly contextNode = this; - constructor(scope: ReactiveScope, model: ModelDefinition, engineConfig: InstanceConfig) { + constructor( + scope: ReactiveScope, + model: ModelDefinition, + secondaryInstances: SecondaryInstancesDefinition, + engineConfig: InstanceConfig + ) { const { root: definition } = model; super(engineConfig, null, definition, { @@ -131,7 +137,7 @@ export class PrimaryInstance const evaluator = new EngineXPathEvaluator({ rootNode: this, itextTranslationsByLanguage: model.itextTranslations, - secondaryInstancesById: model.secondaryInstances, + secondaryInstancesById: secondaryInstances, }); const { languages, getActiveLanguage, setActiveLanguage } = createTranslationState( diff --git a/packages/xforms-engine/src/instance/index.ts b/packages/xforms-engine/src/instance/index.ts index 5ccff2c17..48cfda48b 100644 --- a/packages/xforms-engine/src/instance/index.ts +++ b/packages/xforms-engine/src/instance/index.ts @@ -10,7 +10,9 @@ import type { import { retrieveSourceXMLResource } from '../instance/resource.ts'; import { createReactiveScope } from '../lib/reactivity/scope.ts'; import { createUniqueId } from '../lib/unique-id.ts'; +import { XFormDOM } from '../parse/XFormDOM.ts'; import { XFormDefinition } from '../parse/XFormDefinition.ts'; +import { SecondaryInstancesDefinition } from '../parse/model/SecondaryInstance/SecondaryInstancesDefinition.ts'; import { PrimaryInstance } from './PrimaryInstance.ts'; import type { InstanceConfig } from './internal-api/InstanceConfig.ts'; @@ -37,8 +39,12 @@ export const initializeForm = async ( const sourceXML = await retrieveSourceXMLResource(input, { fetchResource: engineConfig.fetchFormDefinition, }); - const form = new XFormDefinition(sourceXML); - const primaryInstance = new PrimaryInstance(scope, form.model, engineConfig); + const xformDOM = XFormDOM.from(sourceXML); + const secondaryInstances = await SecondaryInstancesDefinition.load(xformDOM, { + fetchResource: engineConfig.fetchFormAttachment, + }); + const form = new XFormDefinition(xformDOM); + const primaryInstance = new PrimaryInstance(scope, form.model, secondaryInstances, engineConfig); return primaryInstance.root; }; diff --git a/packages/xforms-engine/src/parse/XFormDefinition.ts b/packages/xforms-engine/src/parse/XFormDefinition.ts index 41eb96deb..478510e9b 100644 --- a/packages/xforms-engine/src/parse/XFormDefinition.ts +++ b/packages/xforms-engine/src/parse/XFormDefinition.ts @@ -3,7 +3,6 @@ import { ModelDefinition } from './model/ModelDefinition.ts'; import { XFormDOM } from './XFormDOM.ts'; export class XFormDefinition { - readonly xformDOM: XFormDOM; readonly xformDocument: XMLDocument; readonly id: string; @@ -14,11 +13,7 @@ export class XFormDefinition { readonly body: BodyDefinition; readonly model: ModelDefinition; - constructor(readonly sourceXML: string) { - const xformDOM = XFormDOM.from(sourceXML); - - this.xformDOM = xformDOM; - + constructor(readonly xformDOM: XFormDOM) { const { primaryInstanceRoot, title, xformDocument } = xformDOM; const id = primaryInstanceRoot.getAttribute('id'); diff --git a/packages/xforms-engine/src/parse/attachments/FormAttachmentResource.ts b/packages/xforms-engine/src/parse/attachments/FormAttachmentResource.ts new file mode 100644 index 000000000..01f0def86 --- /dev/null +++ b/packages/xforms-engine/src/parse/attachments/FormAttachmentResource.ts @@ -0,0 +1,40 @@ +import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; + +export type FormAttachmentDataType = 'media' | 'secondary-instance'; + +/** + * @todo This type anticipates work to support media form attachments, which + * will tend to be associated with binary data. The + * expectation is that: + * + * - {@link Blob} would be appropriate for representing data from attachment + * resources which are conventionally loaded to completion (where network + * conditions are favorable), such as images + * + * - {@link MediaSource} or {@link ReadableStream} may be more appropriate for + * representing data from resources which are conventionally streamed in a + * browser context (often regardless of network conditions), such as video and + * audio + */ +// prettier-ignore +export type FormAttachmentMediaData = + | Blob + | MediaSource + | ReadableStream; + +export type FormAttachmentSecondaryInstanceData = string; + +// prettier-ignore +type FormAttachmentData = + DataType extends 'media' + ? FormAttachmentMediaData + : FormAttachmentSecondaryInstanceData; + +export abstract class FormAttachmentResource { + protected constructor( + readonly dataType: DataType, + readonly resourceURL: JRResourceURL, + readonly contentType: string, + readonly data: FormAttachmentData + ) {} +} diff --git a/packages/xforms-engine/src/parse/model/ModelDefinition.ts b/packages/xforms-engine/src/parse/model/ModelDefinition.ts index 3e1de03db..73cab44dc 100644 --- a/packages/xforms-engine/src/parse/model/ModelDefinition.ts +++ b/packages/xforms-engine/src/parse/model/ModelDefinition.ts @@ -3,20 +3,17 @@ import { FormSubmissionDefinition } from './FormSubmissionDefinition.ts'; import { ItextTranslationsDefinition } from './ItextTranslation/ItextTranslationsDefinition.ts'; import { ModelBindMap } from './ModelBindMap.ts'; import { RootDefinition } from './RootDefinition.ts'; -import { SecondaryInstancesDefinition } from './SecondaryInstance/SecondaryInstancesDefinition.ts'; export class ModelDefinition { readonly binds: ModelBindMap; readonly root: RootDefinition; readonly itextTranslations: ItextTranslationsDefinition; - readonly secondaryInstances: SecondaryInstancesDefinition; constructor(readonly form: XFormDefinition) { const submission = new FormSubmissionDefinition(form.xformDOM); this.binds = ModelBindMap.fromModel(this); this.root = new RootDefinition(form, this, submission, form.body.classes); this.itextTranslations = ItextTranslationsDefinition.from(form.xformDOM); - this.secondaryInstances = SecondaryInstancesDefinition.from(form.xformDOM); } toJSON() { diff --git a/packages/xforms-engine/src/parse/model/SecondaryInstance/SecondaryInstancesDefinition.ts b/packages/xforms-engine/src/parse/model/SecondaryInstance/SecondaryInstancesDefinition.ts index 952707501..f8d0955d9 100644 --- a/packages/xforms-engine/src/parse/model/SecondaryInstance/SecondaryInstancesDefinition.ts +++ b/packages/xforms-engine/src/parse/model/SecondaryInstance/SecondaryInstancesDefinition.ts @@ -1,29 +1,95 @@ +import { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; +import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; import type { XFormsSecondaryInstanceMap } from '@getodk/xpath'; +import { ErrorProductionDesignPendingError } from '../../../error/ErrorProductionDesignPendingError.ts'; import type { EngineXPathNode } from '../../../integration/xpath/adapter/kind.ts'; -import { parseStaticDocumentFromDOMSubtree } from '../../shared/parseStaticDocumentFromDOMSubtree.ts'; import type { XFormDOM } from '../../XFormDOM.ts'; -import { SecondaryInstanceDefinition } from './SecondaryInstanceDefinition.ts'; import { SecondaryInstanceRootDefinition } from './SecondaryInstanceRootDefinition.ts'; +import { CSVExternalSecondaryInstanceSource } from './sources/CSVExternalSecondaryInstance.ts'; +import type { ExternalSecondaryInstanceResourceOptions } from './sources/ExternalSecondaryInstanceResource.ts'; +import { ExternalSecondaryInstanceResource } from './sources/ExternalSecondaryInstanceResource.ts'; +import { GeoJSONExternalSecondaryInstanceSource } from './sources/GeoJSONExternalSecondaryInstance.ts'; +import { InternalSecondaryInstanceSource } from './sources/InternalSecondaryInstanceSource.ts'; +import type { SecondaryInstanceSource } from './sources/SecondaryInstanceSource.ts'; +import { XMLExternalSecondaryInstanceSource } from './sources/XMLExternalSecondaryInstanceSource.ts'; export class SecondaryInstancesDefinition extends Map implements XFormsSecondaryInstanceMap { - static from(xformDOM: XFormDOM): SecondaryInstancesDefinition { - const definitions = xformDOM.secondaryInstanceElements.map((element) => { - return parseStaticDocumentFromDOMSubtree( - SecondaryInstanceDefinition, - SecondaryInstanceRootDefinition, - element - ); + /** + * @package Only to be used for testing + */ + static loadSync(xformDOM: XFormDOM): SecondaryInstancesDefinition { + const { secondaryInstanceElements } = xformDOM; + const sources = secondaryInstanceElements.map((domElement) => { + const instanceId = domElement.getAttribute('id'); + const src = domElement.getAttribute('src'); + + if (src != null) { + throw new ErrorProductionDesignPendingError( + `Unexpected external secondary instance src attribute: ${src}` + ); + } + + return new InternalSecondaryInstanceSource(instanceId, src, domElement); }); - return new this(definitions); + return new this(sources); + } + + static async load( + xformDOM: XFormDOM, + options: ExternalSecondaryInstanceResourceOptions + ): Promise { + const { secondaryInstanceElements } = xformDOM; + + const sources = await Promise.all( + secondaryInstanceElements.map(async (domElement) => { + const instanceId = domElement.getAttribute('id'); + const src = domElement.getAttribute('src'); + + if (src == null) { + return new InternalSecondaryInstanceSource(instanceId, src, domElement); + } + + if (!JRResourceURL.isJRResourceReference(src)) { + throw new ErrorProductionDesignPendingError( + `Unexpected external secondary instance src attribute: ${src}` + ); + } + + const resourceURL = JRResourceURL.from(src); + const resource = await ExternalSecondaryInstanceResource.load( + instanceId, + resourceURL, + options + ); + + switch (resource.format) { + case 'csv': + return new CSVExternalSecondaryInstanceSource(domElement, resource); + + case 'geojson': + return new GeoJSONExternalSecondaryInstanceSource(domElement, resource); + + case 'xml': + return new XMLExternalSecondaryInstanceSource(domElement, resource); + + default: + throw new UnreachableError(resource); + } + }) + ); + + return new this(sources); } - private constructor(translations: readonly SecondaryInstanceDefinition[]) { + private constructor(sources: readonly SecondaryInstanceSource[]) { super( - translations.map(({ root }) => { + sources.map((source) => { + const { root } = source.parseDefinition(); + return [root.getAttributeValue('id'), root]; }) ); diff --git a/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/CSVExternalSecondaryInstance.ts b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/CSVExternalSecondaryInstance.ts new file mode 100644 index 000000000..b11b9a5f2 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/CSVExternalSecondaryInstance.ts @@ -0,0 +1,9 @@ +import { ErrorProductionDesignPendingError } from '../../../../error/ErrorProductionDesignPendingError.ts'; +import { SecondaryInstanceDefinition } from '../SecondaryInstanceDefinition.ts'; +import { ExternalSecondaryInstanceSource } from './ExternalSecondaryInstanceSource.ts'; + +export class CSVExternalSecondaryInstanceSource extends ExternalSecondaryInstanceSource<'csv'> { + parseDefinition(): SecondaryInstanceDefinition { + throw new ErrorProductionDesignPendingError('CSV external secondary instance support pending'); + } +} diff --git a/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/ExternalSecondaryInstanceResource.ts b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/ExternalSecondaryInstanceResource.ts new file mode 100644 index 000000000..c29c0cbc2 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/ExternalSecondaryInstanceResource.ts @@ -0,0 +1,167 @@ +import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; +import type { FetchResource, FetchResourceResponse } from '../../../../client/resources.ts'; +import { ErrorProductionDesignPendingError } from '../../../../error/ErrorProductionDesignPendingError.ts'; +import { FormAttachmentResource } from '../../../attachments/FormAttachmentResource.ts'; +import type { ExternalSecondaryInstanceSourceFormat } from './SecondaryInstanceSource.ts'; + +const assertResponseSuccess = (resourceURL: JRResourceURL, response: FetchResourceResponse) => { + const { ok = true, status = 200 } = response; + + if (!ok || status !== 200) { + throw new ErrorProductionDesignPendingError(`Failed to load ${resourceURL.href}`); + } +}; + +const stripContentTypeCharset = (contentType: string): string => { + return contentType.replace(/;charset=.*$/, ''); +}; + +const getResponseContentType = (response: FetchResourceResponse): string | null => { + const { headers } = response; + + if (headers == null) { + return null; + } + + const contentType = headers.get('content-type'); + + if (contentType != null) { + return stripContentTypeCharset(contentType); + } + + if (headers instanceof Headers) { + return contentType; + } + + for (const [header, value] of headers) { + if (header.toLowerCase() === 'content-type') { + return stripContentTypeCharset(value); + } + } + + return null; +}; + +interface ExternalSecondaryInstanceResourceMetadata< + Format extends ExternalSecondaryInstanceSourceFormat = ExternalSecondaryInstanceSourceFormat, +> { + readonly contentType: string; + readonly format: Format; +} + +const inferSecondaryInstanceResourceMetadata = ( + resourceURL: JRResourceURL, + contentType: string | null, + data: string +): ExternalSecondaryInstanceResourceMetadata => { + const url = resourceURL.href; + + let format: ExternalSecondaryInstanceSourceFormat | null = null; + + if (url.endsWith('.xml') && data.startsWith('<')) { + format = 'xml'; + } else if (url.endsWith('.csv')) { + format = 'csv'; + } else if (url.endsWith('.geojson') && data.startsWith('{')) { + format = 'geojson'; + } + + if (format == null) { + throw new ErrorProductionDesignPendingError( + `Failed to infer external secondary instance format/content type for resource ${url} (response content type: ${contentType}, data: ${data})` + ); + } + + return { + contentType: contentType ?? 'text/plain', + format, + }; +}; + +const detectSecondaryInstanceResourceMetadata = ( + resourceURL: JRResourceURL, + response: FetchResourceResponse, + data: string +): ExternalSecondaryInstanceResourceMetadata => { + const contentType = getResponseContentType(response); + + if (contentType == null || contentType === 'text/plain') { + return inferSecondaryInstanceResourceMetadata(resourceURL, contentType, data); + } + + let format: ExternalSecondaryInstanceSourceFormat | null = null; + + switch (contentType) { + case 'text/csv': + format = 'csv'; + break; + + case 'application/geo+json': + format = 'geojson'; + break; + + case 'text/xml': + format = 'xml'; + break; + } + + if (format == null) { + throw new ErrorProductionDesignPendingError( + `Failed to detect external secondary instance format for resource ${resourceURL.href} (response content type: ${contentType}, data: ${data})` + ); + } + + return { + contentType, + format, + }; +}; + +export interface ExternalSecondaryInstanceResourceOptions { + readonly fetchResource: FetchResource; +} + +type LoadedExternalSecondaryInstanceResource = { + [Format in ExternalSecondaryInstanceSourceFormat]: ExternalSecondaryInstanceResource; +}[ExternalSecondaryInstanceSourceFormat]; + +export class ExternalSecondaryInstanceResource< + Format extends ExternalSecondaryInstanceSourceFormat = ExternalSecondaryInstanceSourceFormat, +> extends FormAttachmentResource<'secondary-instance'> { + static async load( + instanceId: string, + resourceURL: JRResourceURL, + options: ExternalSecondaryInstanceResourceOptions + ): Promise { + const response = await options.fetchResource(resourceURL); + + assertResponseSuccess(resourceURL, response); + + const data = await response.text(); + const metadata = detectSecondaryInstanceResourceMetadata(resourceURL, response, data); + + return new this( + response.status ?? null, + instanceId, + resourceURL, + metadata, + data + ) satisfies ExternalSecondaryInstanceResource as LoadedExternalSecondaryInstanceResource; + } + + readonly format: Format; + + protected constructor( + readonly responseStatus: number | null, + readonly instanceId: string, + resourceURL: JRResourceURL, + metadata: ExternalSecondaryInstanceResourceMetadata, + data: string + ) { + const { contentType, format } = metadata; + + super('secondary-instance', resourceURL, contentType, data); + + this.format = format; + } +} diff --git a/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/ExternalSecondaryInstanceSource.ts b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/ExternalSecondaryInstanceSource.ts new file mode 100644 index 000000000..6720b81b8 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/ExternalSecondaryInstanceSource.ts @@ -0,0 +1,22 @@ +import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; +import type { DOMSecondaryInstanceElement } from '../../../XFormDOM.ts'; +import type { ExternalSecondaryInstanceResource } from './ExternalSecondaryInstanceResource.ts'; +import type { ExternalSecondaryInstanceSourceFormat } from './SecondaryInstanceSource.ts'; +import { SecondaryInstanceSource } from './SecondaryInstanceSource.ts'; + +export abstract class ExternalSecondaryInstanceSource< + Format extends ExternalSecondaryInstanceSourceFormat = ExternalSecondaryInstanceSourceFormat, +> extends SecondaryInstanceSource { + override readonly resourceURL: JRResourceURL; + + constructor( + domElement: DOMSecondaryInstanceElement, + protected readonly resource: ExternalSecondaryInstanceResource + ) { + const { format, instanceId, resourceURL } = resource; + + super(format, instanceId, resourceURL, domElement); + + this.resourceURL = resourceURL; + } +} diff --git a/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/GeoJSONExternalSecondaryInstance.ts b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/GeoJSONExternalSecondaryInstance.ts new file mode 100644 index 000000000..1913a6b63 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/GeoJSONExternalSecondaryInstance.ts @@ -0,0 +1,11 @@ +import { ErrorProductionDesignPendingError } from '../../../../error/ErrorProductionDesignPendingError.ts'; +import { SecondaryInstanceDefinition } from '../SecondaryInstanceDefinition.ts'; +import { ExternalSecondaryInstanceSource } from './ExternalSecondaryInstanceSource.ts'; + +export class GeoJSONExternalSecondaryInstanceSource extends ExternalSecondaryInstanceSource<'geojson'> { + parseDefinition(): SecondaryInstanceDefinition { + throw new ErrorProductionDesignPendingError( + 'GeoJSON external secondary instance support pending' + ); + } +} diff --git a/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/InternalSecondaryInstanceSource.ts b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/InternalSecondaryInstanceSource.ts new file mode 100644 index 000000000..cb16e10f7 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/InternalSecondaryInstanceSource.ts @@ -0,0 +1,19 @@ +import { parseStaticDocumentFromDOMSubtree } from '../../../shared/parseStaticDocumentFromDOMSubtree.ts'; +import type { DOMSecondaryInstanceElement } from '../../../XFormDOM.ts'; +import { SecondaryInstanceDefinition } from '../SecondaryInstanceDefinition.ts'; +import { SecondaryInstanceRootDefinition } from '../SecondaryInstanceRootDefinition.ts'; +import { SecondaryInstanceSource } from './SecondaryInstanceSource.ts'; + +export class InternalSecondaryInstanceSource extends SecondaryInstanceSource<'internal'> { + constructor(instanceId: string, resourceURL: null, domElement: DOMSecondaryInstanceElement) { + super('internal', instanceId, resourceURL, domElement); + } + + parseDefinition(): SecondaryInstanceDefinition { + return parseStaticDocumentFromDOMSubtree( + SecondaryInstanceDefinition, + SecondaryInstanceRootDefinition, + this.domElement + ); + } +} diff --git a/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/SecondaryInstanceSource.ts b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/SecondaryInstanceSource.ts new file mode 100644 index 000000000..a8c133990 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/SecondaryInstanceSource.ts @@ -0,0 +1,27 @@ +import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; +import type { DOMSecondaryInstanceElement } from '../../../XFormDOM.ts'; +import type { SecondaryInstanceDefinition } from '../SecondaryInstanceDefinition.ts'; + +// prettier-ignore +export type ExternalSecondaryInstanceSourceFormat = + | 'csv' + | 'geojson' + | 'xml'; + +// prettier-ignore +export type SecondaryInstanceSourceFormat = + | ExternalSecondaryInstanceSourceFormat + | 'internal'; + +export abstract class SecondaryInstanceSource< + Format extends SecondaryInstanceSourceFormat = SecondaryInstanceSourceFormat, +> { + constructor( + readonly format: Format, + readonly instanceId: string, + readonly resourceURL: JRResourceURL | null, + readonly domElement: DOMSecondaryInstanceElement + ) {} + + abstract parseDefinition(): SecondaryInstanceDefinition; +} diff --git a/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/XMLExternalSecondaryInstanceSource.ts b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/XMLExternalSecondaryInstanceSource.ts new file mode 100644 index 000000000..ef7985eb7 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/SecondaryInstance/sources/XMLExternalSecondaryInstanceSource.ts @@ -0,0 +1,32 @@ +import { XFORMS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import { parseStaticDocumentFromDOMSubtree } from '../../../shared/parseStaticDocumentFromDOMSubtree.ts'; +import { SecondaryInstanceDefinition } from '../SecondaryInstanceDefinition.ts'; +import { SecondaryInstanceRootDefinition } from '../SecondaryInstanceRootDefinition.ts'; +import { ExternalSecondaryInstanceSource } from './ExternalSecondaryInstanceSource.ts'; +import type { InternalSecondaryInstanceSource } from './InternalSecondaryInstanceSource.ts'; + +export class XMLExternalSecondaryInstanceSource extends ExternalSecondaryInstanceSource<'xml'> { + /** + * Note: this logic is a superset of the logic in + * {@link InternalSecondaryInstanceSource.parseDefinition}. That subset is so + * trivial/already sufficiently abstracted that it doesn't really make a lot + * of sense to abstract further, but it might be worth considering if both + * method implementations grow their responsibilities in the same ways. + */ + parseDefinition(): SecondaryInstanceDefinition { + const xmlDocument = this.domElement.ownerDocument.implementation.createDocument( + XFORMS_NAMESPACE_URI, + 'instance' + ); + const instanceElement = xmlDocument.documentElement; + + instanceElement.setAttribute('id', this.instanceId); + instanceElement.innerHTML = this.resource.data; + + return parseStaticDocumentFromDOMSubtree( + SecondaryInstanceDefinition, + SecondaryInstanceRootDefinition, + instanceElement + ); + } +} diff --git a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts index 1d16d8b76..d541fcd37 100644 --- a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts +++ b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts @@ -20,12 +20,15 @@ import { Root } from '../../src/instance/Root.ts'; import { InstanceNode } from '../../src/instance/abstract/InstanceNode.ts'; import { createReactiveScope, type ReactiveScope } from '../../src/lib/reactivity/scope.ts'; import { createUniqueId } from '../../src/lib/unique-id.ts'; +import { XFormDOM } from '../../src/parse/XFormDOM.ts'; import { XFormDefinition } from '../../src/parse/XFormDefinition.ts'; +import { SecondaryInstancesDefinition } from '../../src/parse/model/SecondaryInstance/SecondaryInstancesDefinition.ts'; import { reactiveTestScope } from '../helpers/reactive/internal.ts'; describe('PrimaryInstance engine representation of instance state', () => { let scope: ReactiveScope; let xformDefinition: XFormDefinition; + let secondaryInstances: SecondaryInstancesDefinition; beforeEach(() => { scope = createReactiveScope(); @@ -58,7 +61,10 @@ describe('PrimaryInstance engine representation of instance state', () => { ) ); - xformDefinition = new XFormDefinition(xform.asXml()); + const xformDOM = XFormDOM.from(xform.asXml()); + + xformDefinition = new XFormDefinition(xformDOM); + secondaryInstances = SecondaryInstancesDefinition.loadSync(xformDOM); }); afterEach(() => { @@ -75,7 +81,7 @@ describe('PrimaryInstance engine representation of instance state', () => { // directly, with caution. const createPrimaryInstance = (stateFactory: OpaqueReactiveObjectFactory): PrimaryInstance => { return scope.runTask(() => { - return new PrimaryInstance(scope, xformDefinition.model, { + return new PrimaryInstance(scope, xformDefinition.model, secondaryInstances, { createUniqueId, fetchFormAttachment: fetchResource, fetchFormDefinition: fetchResource, diff --git a/packages/xforms-engine/test/parse/XFormDefinition.test.ts b/packages/xforms-engine/test/parse/XFormDefinition.test.ts index cb043c2b3..68139bc83 100644 --- a/packages/xforms-engine/test/parse/XFormDefinition.test.ts +++ b/packages/xforms-engine/test/parse/XFormDefinition.test.ts @@ -14,6 +14,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { BodyDefinition } from '../../src/parse/body/BodyDefinition.ts'; import { ModelDefinition } from '../../src/parse/model/ModelDefinition.ts'; import { XFormDefinition } from '../../src/parse/XFormDefinition.ts'; +import { XFormDOM } from '../../src/parse/XFormDOM.ts'; describe('XFormDefinition', () => { const FORM_TITLE = 'Minimal XForm'; @@ -48,7 +49,9 @@ describe('XFormDefinition', () => { ) ); - xformDefinition = new XFormDefinition(xform.asXml()); + const xformDOM = XFormDOM.from(xform.asXml()); + + xformDefinition = new XFormDefinition(xformDOM); }); it('defines the form title', () => { diff --git a/packages/xforms-engine/test/parse/body/BodyDefinition.test.ts b/packages/xforms-engine/test/parse/body/BodyDefinition.test.ts index 0b7425cc4..7e7748499 100644 --- a/packages/xforms-engine/test/parse/body/BodyDefinition.test.ts +++ b/packages/xforms-engine/test/parse/body/BodyDefinition.test.ts @@ -15,6 +15,7 @@ import { import { beforeEach, describe, expect, it } from 'vitest'; import type { BodyDefinition } from '../../../src/parse/body/BodyDefinition.ts'; import { XFormDefinition } from '../../../src/parse/XFormDefinition.ts'; +import { XFormDOM } from '../../../src/parse/XFormDOM.ts'; describe('BodyDefinition', () => { let bodyDefinition: BodyDefinition; @@ -175,7 +176,9 @@ describe('BodyDefinition', () => { ) ) ); - const xformDefinition = new XFormDefinition(xform.asXml()); + + const xformDOM = XFormDOM.from(xform.asXml()); + const xformDefinition = new XFormDefinition(xformDOM); bodyDefinition = xformDefinition.body; }); diff --git a/packages/xforms-engine/test/parse/model/BindDefinition.test.ts b/packages/xforms-engine/test/parse/model/BindDefinition.test.ts index 0b64c5da8..a9008aa78 100644 --- a/packages/xforms-engine/test/parse/model/BindDefinition.test.ts +++ b/packages/xforms-engine/test/parse/model/BindDefinition.test.ts @@ -10,6 +10,7 @@ import { } from '@getodk/common/test/fixtures/xform-dsl/index.ts'; import { describe, expect, it } from 'vitest'; import { XFormDefinition } from '../../../src/parse/XFormDefinition.ts'; +import { XFormDOM } from '../../../src/parse/XFormDOM.ts'; describe('BindDefinition', () => { it.each([ @@ -45,7 +46,8 @@ describe('BindDefinition', () => { body() ); - const xformDefinition = new XFormDefinition(xform.asXml()); + const xformDOM = XFormDOM.from(xform.asXml()); + const xformDefinition = new XFormDefinition(xformDOM); const bindDefinition = xformDefinition.model.binds.get('/root/first-question'); expect(bindDefinition!.dataType).toEqual(expected); @@ -90,7 +92,8 @@ describe('BindDefinition', () => { body() ); - const xformDefinition = new XFormDefinition(xform.asXml()); + const xformDOM = XFormDOM.from(xform.asXml()); + const xformDefinition = new XFormDefinition(xformDOM); const bindDefinition = xformDefinition.model.binds.get('/root/first-question'); expect(bindDefinition![computation]?.toString() ?? null).toEqual(expression); diff --git a/packages/xforms-engine/test/parse/model/ModelBindMap.test.ts b/packages/xforms-engine/test/parse/model/ModelBindMap.test.ts index 66e7b0541..358bc77f8 100644 --- a/packages/xforms-engine/test/parse/model/ModelBindMap.test.ts +++ b/packages/xforms-engine/test/parse/model/ModelBindMap.test.ts @@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { BindDefinition } from '../../../src/parse/model/BindDefinition.ts'; import type { ModelBindMap } from '../../../src/parse/model/ModelBindMap.ts'; import { XFormDefinition } from '../../../src/parse/XFormDefinition.ts'; +import { XFormDOM } from '../../../src/parse/XFormDOM.ts'; describe('ModelBindMap', () => { let binds: ModelBindMap; @@ -50,7 +51,8 @@ describe('ModelBindMap', () => { ), body() ); - const form = new XFormDefinition(xform.asXml()); + const xformDOM = XFormDOM.from(xform.asXml()); + const form = new XFormDefinition(xformDOM); binds = form.model.binds; }); diff --git a/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts b/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts index e3945323f..480d502bb 100644 --- a/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts +++ b/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts @@ -20,6 +20,7 @@ import type { LeafNodeDefinition } from '../../../src/parse/model/LeafNodeDefini import { ModelDefinition } from '../../../src/parse/model/ModelDefinition.ts'; import type { RepeatRangeDefinition } from '../../../src/parse/model/RepeatRangeDefinition.ts'; import { XFormDefinition } from '../../../src/parse/XFormDefinition.ts'; +import { XFormDOM } from '../../../src/parse/XFormDOM.ts'; describe('ModelDefinition', () => { let modelDefinition: ModelDefinition; @@ -49,7 +50,8 @@ describe('ModelDefinition', () => { ) ); - const xformDefinition = new XFormDefinition(xform.asXml()); + const xformDOM = XFormDOM.from(xform.asXml()); + const xformDefinition = new XFormDefinition(xformDOM); modelDefinition = xformDefinition.model; }); @@ -164,7 +166,8 @@ describe('ModelDefinition', () => { ) ); - const xformDefinition = new XFormDefinition(xform.asXml()); + const xformDOM = XFormDOM.from(xform.asXml()); + const xformDefinition = new XFormDefinition(xformDOM); modelDefinition = xformDefinition.model; }); @@ -313,7 +316,8 @@ describe('ModelDefinition', () => { ) ); - const xformDefinition = new XFormDefinition(xform.asXml()); + const xformDOM = XFormDOM.from(xform.asXml()); + const xformDefinition = new XFormDefinition(xformDOM); modelDefinition = xformDefinition.model; }); @@ -474,7 +478,9 @@ describe('ModelDefinition', () => { ) ); - expect(() => new XFormDefinition(xform.asXml())).toThrow( + const xformDOM = XFormDOM.from(xform.asXml()); + + expect(() => new XFormDefinition(xformDOM)).toThrow( 'Multiple explicit templates defined for /root/rep/rep2' ); }); From edf14f648462e9f7fcf276882b7aa64bd38bd6df Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Mon, 25 Nov 2024 14:00:43 -0800 Subject: [PATCH 11/27] web-forms (Vue UI): auto setup JRResourceService to serve/load form attachments As of this commit, this allows local dev preview of XML external secondary instances when: - Previewing a fixture from the local file system - The secondary instance resources are located in the same local file system directory, referencing the same file name as specified in their `jr:` URL --- packages/common/src/fixtures/xforms.ts | 42 +++++++++++++++++++ .../web-forms/src/components/OdkWebForm.vue | 8 +++- packages/web-forms/src/demo/FormPreview.vue | 19 ++++++--- .../tests/components/OdkWebForm.test.ts | 3 ++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/common/src/fixtures/xforms.ts b/packages/common/src/fixtures/xforms.ts index 75d140fab..1c27e1f28 100644 --- a/packages/common/src/fixtures/xforms.ts +++ b/packages/common/src/fixtures/xforms.ts @@ -1,24 +1,40 @@ +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 = { @@ -71,6 +87,10 @@ const xformURLLoader = (url: URL): LoadXFormXML => { }; }; +const getNoopResourceService: ResourceServiceFactory = () => { + return new JRResourceService(); +}; + export class XFormResource { static forLocalFixture( localPath: string, @@ -81,6 +101,14 @@ export class XFormResource { category: localFixtureDirectoryCategory(localPath), localPath, identifier: pathToFileName(localPath), + initializeFormAttachmentService: () => { + const service = new JRResourceService(); + const parentPath = localPath.replace(/\/[^/]+$/, ''); + + service.activateFixtures(parentPath, ['file', 'file-csv']); + + return service; + }, }); } @@ -92,6 +120,8 @@ export class XFormResource { const loadXML = xformURLLoader(resourceURL); return new XFormResource('remote', resourceURL, loadXML, { + ...options, + category: options?.category ?? 'other', identifier: options?.identifier ?? extractURLIdentifier(resourceURL), localPath: options?.localPath ?? null, @@ -101,6 +131,7 @@ export class XFormResource { readonly category: string; readonly localPath: XFormResourceOptions['localPath']; readonly identifier: XFormResourceOptions['identifier']; + readonly fetchFormAttachment: (url: JRResourceURL) => Promise; private constructor( readonly resourceType: Type, @@ -111,6 +142,17 @@ export class XFormResource { this.category = options.category ?? 'other'; this.localPath = options.localPath; this.identifier = options.identifier; + + const initializeFormAttachmentService = + options.initializeFormAttachmentService ?? getNoopResourceService; + + let resourceService: JRResourceService | null = null; + + this.fetchFormAttachment = (url) => { + resourceService = resourceService ?? initializeFormAttachmentService(); + + return resourceService.handleRequest(url); + }; } } diff --git a/packages/web-forms/src/components/OdkWebForm.vue b/packages/web-forms/src/components/OdkWebForm.vue index be7730580..b9fdd9305 100644 --- a/packages/web-forms/src/components/OdkWebForm.vue +++ b/packages/web-forms/src/components/OdkWebForm.vue @@ -1,5 +1,5 @@