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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/angular/src/http-resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3333,6 +3333,74 @@ describe('angular httpResource generator', () => {
expect(mapImportLine).toContain("from 'rxjs'");
}
});

// A rename cannot desynchronise `sharedExports`, but a new shared
// declaration that nobody adds to the list can. This catches that.
it('declares exactly the names its per-tag resource files share', async () => {
const petsVerb = createVerbOption({ tags: ['Pets'] });
const healthVerb = createVerbOption({
operationId: 'getHealth',
operationName: 'getHealth',
route: '/health',
pathRoute: '/health',
tags: ['Health'],
params: [],
props: [],
});

const output = createOutput({
target: '/tmp/endpoints.ts',
mode: 'tags-split',
override: {
...createOutput().override,
angular: {
...angularOverride('both'),
},
},
});

const context = createContextSpec(output, {
workspace: '/tmp',
target: '/tmp/endpoints.ts',
projectName: 'pets',
});

const extraFiles = await generateHttpResourceExtraFiles(
{ getPetById: petsVerb, getHealth: healthVerb },
output,
context,
);

expect(extraFiles).toHaveLength(2);

const exportedNames = (content: string): Set<string> =>
new Set(
content
.split('\n')
.map(
(line) =>
line.match(
/^export\s+(?:declare\s+)?(?:abstract\s+)?(?:type|interface|const|let|var|function|class|enum)\s+([A-Za-z_$][\w$]*)/,
)?.[1],
)
.filter((name): name is string => name !== undefined),
);

const [first, second] = extraFiles;
const secondNames = exportedNames(second.content);
const actuallyShared = [...exportedNames(first.content)]
.filter((name) => secondNames.has(name))
.toSorted();

expect(actuallyShared.length).toBeGreaterThan(0);
expect(
[
...(first.sharedExports?.types ?? []),
...(first.sharedExports?.values ?? []),
].toSorted(),
).toEqual(actuallyShared);
expect(second.sharedExports).toEqual(first.sharedExports);
});
});

// ── urlEncodeParameters ─────────────────────────────────────────────
Expand Down
42 changes: 36 additions & 6 deletions packages/angular/src/http-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
OutputMode,
pascal,
type ResReqTypesValue,
type SharedExports,
toObjectString,
upath,
getImportExtension,
Expand Down Expand Up @@ -298,6 +299,31 @@ type HttpResourceFactoryName =
| 'httpResource.blob';

const HTTP_RESOURCE_OPTIONS_TYPE_NAME = 'OrvalHttpResourceOptions';
const HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME =
'OrvalHttpResourceRequestExtension';
const RESOURCE_STATE_TYPE_NAME = 'ResourceState';
const APPLY_REQUEST_EXTENSION_FUNCTION_NAME = 'applyOrvalRequestExtension';
const TO_RESOURCE_STATE_FUNCTION_NAME = 'toResourceState';

/**
* Boilerplate that every generated `*.resource.ts` declares. In a tag-based
* mode each tag repeats it. The barrel writer needs the list to prevent
* TS2308. See `buildBarrelReExports`.
*
* These are the same constants that the templates interpolate, so a rename
* cannot desynchronise the two.
*/
const HTTP_RESOURCE_SHARED_EXPORTS: SharedExports = {
types: [
HTTP_RESOURCE_OPTIONS_TYPE_NAME,
HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME,
RESOURCE_STATE_TYPE_NAME,
],
values: [
APPLY_REQUEST_EXTENSION_FUNCTION_NAME,
TO_RESOURCE_STATE_FUNCTION_NAME,
],
};

const getHttpResourceFactory = (
response: { readonly isBlob: boolean },
Expand Down Expand Up @@ -1221,7 +1247,7 @@ export function ${resourceName}(${implementationArgs}): HttpResourceRef<${resour
};

const buildHttpResourceOptionsUtilities = (omitParse: boolean): string => `
export interface OrvalHttpResourceRequestExtension {
export interface ${HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME} {
/** Extra headers merged over generated headers. Pass a function to read signals reactively. */
headers?: HttpResourceRequest['headers'] | (() => HttpResourceRequest['headers']);
/** Angular HttpContext forwarded to the underlying request. Pass a function to derive it reactively. */
Expand All @@ -1234,7 +1260,7 @@ export type ${HTTP_RESOURCE_OPTIONS_TYPE_NAME}<TValue, TRaw = unknown, TOmitPars
(TOmitParse extends true
? Omit<HttpResourceOptions<TValue, TRaw>, 'parse'>
: HttpResourceOptions<TValue, TRaw>) &
OrvalHttpResourceRequestExtension;
${HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME};

function mergeOrvalResourceHeaders(
base: HttpResourceRequest['headers'],
Expand Down Expand Up @@ -1270,9 +1296,9 @@ function mergeOrvalResourceHeaders(
return { ...base, ...extra };
}

export function applyOrvalRequestExtension(
export function ${APPLY_REQUEST_EXTENSION_FUNCTION_NAME}(
request: string | HttpResourceRequest,
options?: OrvalHttpResourceRequestExtension,
options?: ${HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME},
): HttpResourceRequest {
const base: HttpResourceRequest = typeof request === 'string' ? { url: request } : request;
if (
Expand Down Expand Up @@ -1349,7 +1375,7 @@ const buildResourceStateUtilities = (): string => `
*
* Uses \`globalThis.Error\` to avoid collision with API model types named \`Error\`.
*/
export interface ResourceState<T> {
export interface ${RESOURCE_STATE_TYPE_NAME}<T> {
readonly value: Signal<T | undefined>;
readonly status: Signal<ResourceStatus>;
readonly error: Signal<globalThis.Error | undefined>;
Expand All @@ -1362,7 +1388,7 @@ export interface ResourceState<T> {
* Wraps an HttpResourceRef to expose a consistent ResourceState interface.
* Useful when integrating with NgRx SignalStore via withResource().
*/
export function toResourceState<T>(ref: HttpResourceRef<T>): ResourceState<T> {
export function ${TO_RESOURCE_STATE_FUNCTION_NAME}<T>(ref: HttpResourceRef<T>): ${RESOURCE_STATE_TYPE_NAME}<T> {
return {
value: ref.value,
status: ref.status,
Expand Down Expand Up @@ -1774,6 +1800,10 @@ const buildHttpResourceExtraFile = (
return {
content: `${header}${importImplementation}${mutatorImports}${implementation}`,
path: outputPath,
// Part of the public client surface, so the `tags-split` barrel re-exports
// it.
barrelExport: true,
sharedExports: HTTP_RESOURCE_SHARED_EXPORTS,
};
};

Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1778,9 +1778,37 @@ export type ClientBuilder = (
output?: NormalizedOutputOptions,
) => GeneratorClient | Promise<GeneratorClient>;

/**
* Names that a generated file declares and that its sibling files of the same
* kind declare identically. The barrel writer uses them to prevent TS2308.
* See `buildBarrelReExports` for the rule.
*/
export interface SharedExports {
/**
* Type-only declarations. Re-exported via `export type { ... }` — kept
* separate from {@link SharedExports.values} because a type re-exported
* without the `type` modifier is an error under `verbatimModuleSyntax`,
* which Angular projects enable by default.
*/
readonly types: readonly string[];
/** Value declarations, re-exported via `export { ... }`. */
readonly values: readonly string[];
}

export interface ClientFileBuilder {
path: string;
content: string;
/**
* Set this to re-export the file from the `tags-split` barrel. Omit it when
* the file is not part of the public client surface, or when the file runs
* code at module level and an import must not start that code.
*/
barrelExport?: boolean;
/**
* Declared by generators whose extra files repeat shared boilerplate. Omit
* when a file declares nothing its siblings also declare.
*/
sharedExports?: SharedExports;
}
export type ClientExtraFilesBuilder = (
verbOptions: Record<string, GeneratorVerbOptions>,
Expand Down
139 changes: 139 additions & 0 deletions packages/core/src/utils/barrel-re-exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';

import { buildBarrelReExports } from './barrel-re-exports';

const options = { dirname: '/out', extension: '.ts', importExtension: '' };

// Mirrors Angular's `retrievalClient: 'both'` output: one file per tag, each
// repeating the same httpResource boilerplate.
const resourceFile = (tag: string) => ({
path: `/out/${tag}/${tag}.resource.ts`,
sharedExports: {
types: ['OrvalHttpResourceOptions', 'ResourceState'],
values: ['toResourceState'],
},
});

describe('buildBarrelReExports', () => {
it('wildcard-exports every file, in path order', () => {
expect(
buildBarrelReExports(
[
{ path: '/out/pets/pets.resource.ts' },
{ path: '/out/health/health.resource.ts' },
],
options,
),
).toEqual([
"export * from './health/health.resource';",
"export * from './pets/pets.resource';",
]);
});

it('re-exports names declared by more than one file ahead of the wildcards', () => {
const lines = buildBarrelReExports(
[resourceFile('health'), resourceFile('pets')],
options,
);

expect(lines).toEqual([
"export type { OrvalHttpResourceOptions, ResourceState } from './health/health.resource';",
"export { toResourceState } from './health/health.resource';",
"export * from './health/health.resource';",
"export * from './pets/pets.resource';",
]);
});

it('leaves a name declared by a single file on its wildcard', () => {
const lines = buildBarrelReExports(
[
resourceFile('health'),
{
path: '/out/pets/pets.resource.ts',
sharedExports: { types: ['SomethingElse'], values: [] },
},
],
options,
);

expect(lines.join('\n')).not.toContain('SomethingElse');
expect(lines.join('\n')).not.toContain('OrvalHttpResourceOptions');
});

it('attributes each shared name to the first file declaring it', () => {
// `ResourceState` is absent from the first file, so intersecting the
// shared set with file one would drop it and leave TS2308 unresolved.
const lines = buildBarrelReExports(
[
{
path: '/out/a/a.resource.ts',
sharedExports: { types: ['Shared'], values: [] },
},
{
path: '/out/b/b.resource.ts',
sharedExports: { types: ['Shared', 'ResourceState'], values: [] },
},
{
path: '/out/c/c.resource.ts',
sharedExports: { types: ['ResourceState'], values: [] },
},
],
options,
);

expect(lines).toEqual([
"export type { Shared } from './a/a.resource';",
"export type { ResourceState } from './b/b.resource';",
"export * from './a/a.resource';",
"export * from './b/b.resource';",
"export * from './c/c.resource';",
]);
});

it('skips names the barrel already re-exports by name', () => {
const lines = buildBarrelReExports(
[resourceFile('health'), resourceFile('pets')],
options,
['ResourceState'],
);

expect(lines).toContain(
"export type { OrvalHttpResourceOptions } from './health/health.resource';",
);
expect(lines.join('\n')).not.toContain(
'export type { OrvalHttpResourceOptions, ResourceState }',
);
});

it('ignores files that declare no shared exports', () => {
const lines = buildBarrelReExports(
[
{ path: '/out/pets/pets.handlers.ts' },
{ path: '/out/health/health.handlers.ts' },
],
options,
);

expect(lines.every((line) => line.startsWith('export *'))).toBe(true);
});

it('ignores files outside the barrel directory', () => {
expect(
buildBarrelReExports([{ path: '/somewhere-else.resource.ts' }], options),
).toEqual([]);
});

it('strips a multi-part extension in one piece and adds the import extension', () => {
expect(
buildBarrelReExports([{ path: '/out/pets/pets.resource.generated.ts' }], {
dirname: '/out',
extension: '.generated.ts',
importExtension: '.js',
}),
).toEqual(["export * from './pets/pets.resource.js';"]);
});

it('returns nothing for no files', () => {
expect(buildBarrelReExports([], options)).toEqual([]);
});
});
Loading
Loading