Skip to content

Commit f548b4e

Browse files
authored
Merge pull request #948 from constructive-io/devin/1775032006-wire-presigned-url-preset
feat: wire PresignedUrlPreset into ConstructivePreset (Step 2g)
2 parents 9356877 + 122eb8e commit f548b4e

23 files changed

Lines changed: 566 additions & 36 deletions

File tree

graphile/graphile-presigned-url-plugin/src/download-url-field.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import type { GraphileConfig } from 'graphile-config';
1717
import { Logger } from '@pgpmjs/logger';
1818

19-
import type { PresignedUrlPluginOptions } from './types';
19+
import type { PresignedUrlPluginOptions, S3Config } from './types';
2020
import { generatePresignedGetUrl } from './s3-signer';
2121
import { getStorageModuleConfig } from './storage-module-cache';
2222

@@ -31,10 +31,22 @@ const log = new Logger('graphile-presigned-url:download-url');
3131
* the storage module's files table, which we discover at schema-build time
3232
* via the `@storageFiles` smart tag.
3333
*/
34+
/**
35+
* Resolve the S3 config from the options. If the option is a lazy getter
36+
* function, call it (and cache the result).
37+
*/
38+
function resolveS3(options: PresignedUrlPluginOptions): S3Config {
39+
if (typeof options.s3 === 'function') {
40+
const resolved = options.s3();
41+
options.s3 = resolved;
42+
return resolved;
43+
}
44+
return options.s3;
45+
}
46+
3447
export function createDownloadUrlPlugin(
3548
options: PresignedUrlPluginOptions,
3649
): GraphileConfig.Plugin {
37-
const { s3 } = options;
3850

3951
return {
4052
name: 'PresignedUrlDownloadPlugin',
@@ -88,6 +100,8 @@ export function createDownloadUrlPlugin(
88100
return null;
89101
}
90102

103+
const s3 = resolveS3(options);
104+
91105
if (isPublic && s3.publicUrlPrefix) {
92106
// Public file: return direct URL
93107
return `${s3.publicUrlPrefix}/${key}`;
@@ -118,7 +132,7 @@ export function createDownloadUrlPlugin(
118132

119133
// Private file: generate presigned GET URL
120134
return generatePresignedGetUrl(
121-
s3,
135+
resolveS3(options),
122136
key,
123137
downloadUrlExpirySeconds,
124138
filename || undefined,

graphile/graphile-presigned-url-plugin/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ export type {
4040
ConfirmUploadInput,
4141
ConfirmUploadPayload,
4242
S3Config,
43+
S3ConfigOrGetter,
4344
PresignedUrlPluginOptions,
4445
} from './types';

graphile/graphile-presigned-url-plugin/src/plugin.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { GraphileConfig } from 'graphile-config';
2222
import { extendSchema, gql } from 'graphile-utils';
2323
import { Logger } from '@pgpmjs/logger';
2424

25-
import type { PresignedUrlPluginOptions } from './types';
25+
import type { PresignedUrlPluginOptions, S3Config } from './types';
2626
import { getStorageModuleConfig, getBucketConfig } from './storage-module-cache';
2727
import { generatePresignedPutUrl, headObject } from './s3-signer';
2828

@@ -67,10 +67,24 @@ async function resolveDatabaseId(pgClient: any): Promise<string | null> {
6767

6868
// --- Plugin factory ---
6969

70+
/**
71+
* Resolve the S3 config from the options. If the option is a lazy getter
72+
* function, call it (and cache the result). This avoids reading env vars
73+
* or constructing an S3Client at module-import time.
74+
*/
75+
function resolveS3(options: PresignedUrlPluginOptions): S3Config {
76+
if (typeof options.s3 === 'function') {
77+
const resolved = options.s3();
78+
// Cache so subsequent calls don't re-evaluate
79+
options.s3 = resolved;
80+
return resolved;
81+
}
82+
return options.s3;
83+
}
84+
7085
export function createPresignedUrlPlugin(
7186
options: PresignedUrlPluginOptions,
7287
): GraphileConfig.Plugin {
73-
const { s3 } = options;
7488

7589
return extendSchema(() => ({
7690
typeDefs: gql`
@@ -272,7 +286,7 @@ export function createPresignedUrlPlugin(
272286

273287
// --- Generate presigned PUT URL ---
274288
const uploadUrl = await generatePresignedPutUrl(
275-
s3,
289+
resolveS3(options),
276290
s3Key,
277291
contentType,
278292
size,
@@ -362,7 +376,7 @@ export function createPresignedUrlPlugin(
362376
}
363377

364378
// --- Verify file exists in S3 ---
365-
const s3Head = await headObject(s3, file.key, file.content_type);
379+
const s3Head = await headObject(resolveS3(options), file.key, file.content_type);
366380

367381
if (!s3Head) {
368382
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');

graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ const STORAGE_MODULE_QUERY = `
4444
ft.name AS files_table,
4545
urs.schema_name AS upload_requests_schema,
4646
urt.name AS upload_requests_table,
47+
sm.endpoint,
48+
sm.public_url_prefix,
49+
sm.provider,
4750
sm.upload_url_expiry_seconds,
4851
sm.download_url_expiry_seconds,
4952
sm.default_max_file_size,
@@ -68,6 +71,9 @@ interface StorageModuleRow {
6871
files_table: string;
6972
upload_requests_schema: string;
7073
upload_requests_table: string;
74+
endpoint: string | null;
75+
public_url_prefix: string | null;
76+
provider: string | null;
7177
upload_url_expiry_seconds: number | null;
7278
download_url_expiry_seconds: number | null;
7379
default_max_file_size: number | null;
@@ -112,6 +118,9 @@ export async function getStorageModuleConfig(
112118
bucketsTableName: row.buckets_table,
113119
filesTableName: row.files_table,
114120
uploadRequestsTableName: row.upload_requests_table,
121+
endpoint: row.endpoint,
122+
publicUrlPrefix: row.public_url_prefix,
123+
provider: row.provider,
115124
uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
116125
downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
117126
defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,

graphile/graphile-presigned-url-plugin/src/types.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export interface StorageModuleConfig {
3434
/** Upload requests table name */
3535
uploadRequestsTableName: string;
3636

37+
// --- S3 connection config (NULL in DB = use global env/plugin defaults) ---
38+
39+
/** S3-compatible API endpoint URL (per-database override) */
40+
endpoint: string | null;
41+
/** Public URL prefix for generating download URLs (per-database override) */
42+
publicUrlPrefix: string | null;
43+
/** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
44+
provider: string | null;
45+
3746
// --- Per-database configurable settings ---
3847

3948
/** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
@@ -118,10 +127,18 @@ export interface S3Config {
118127
publicUrlPrefix?: string;
119128
}
120129

130+
/**
131+
* S3 configuration or a lazy getter that returns it on first use.
132+
* When a function is provided, it will only be called when the first
133+
* mutation or resolver actually needs the S3 client — avoiding eager
134+
* env-var reads and S3Client creation at module import time.
135+
*/
136+
export type S3ConfigOrGetter = S3Config | (() => S3Config);
137+
121138
/**
122139
* Plugin options for the presigned URL plugin.
123140
*/
124141
export interface PresignedUrlPluginOptions {
125-
/** S3 configuration */
126-
s3: S3Config;
142+
/** S3 configuration (concrete or lazy getter) */
143+
s3: S3ConfigOrGetter;
127144
}

graphile/graphile-settings/__tests__/upload-resolver.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async function loadUploadResolverModule(opts: {
3535
awsRegion: 'us-east-1',
3636
awsAccessKey: 'test',
3737
awsSecretKey: 'test',
38-
minioEndpoint: 'http://localhost:9000',
38+
endpoint: 'http://localhost:9000',
3939
},
4040
})),
4141
}));

graphile/graphile-settings/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"test:watch": "jest --watch"
3030
},
3131
"dependencies": {
32+
"@aws-sdk/client-s3": "^3.1009.0",
3233
"@constructive-io/graphql-env": "workspace:^",
3334
"@constructive-io/graphql-types": "workspace:^",
3435
"@constructive-io/s3-streamer": "workspace:^",
@@ -48,6 +49,7 @@
4849
"graphile-config": "1.0.0",
4950
"graphile-connection-filter": "workspace:^",
5051
"graphile-postgis": "workspace:^",
52+
"graphile-presigned-url-plugin": "workspace:^",
5153
"graphile-search": "workspace:^",
5254
"graphile-sql-expression-validator": "workspace:^",
5355
"graphile-upload-plugin": "workspace:^",

graphile/graphile-settings/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,6 @@ export { makePgService };
5959

6060
// Upload utilities
6161
export { streamToStorage } from './upload-resolver';
62+
63+
// Presigned URL utilities
64+
export { getPresignedUrlS3Config } from './presigned-url-resolver';

graphile/graphile-settings/src/presets/constructive-preset.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import {
1515
import { UnifiedSearchPreset, createMatchesOperatorFactory, createTrgmOperatorFactories } from 'graphile-search';
1616
import { GraphilePostgisPreset, createPostgisOperatorFactory } from 'graphile-postgis';
1717
import { UploadPreset } from 'graphile-upload-plugin';
18+
import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
1819
import { SqlExpressionValidatorPreset } from 'graphile-sql-expression-validator';
1920
import { constructiveUploadFieldDefinitions } from '../upload-resolver';
21+
import { getPresignedUrlS3Config } from '../presigned-url-resolver';
2022

2123
/**
2224
* Constructive PostGraphile v5 Preset
@@ -36,6 +38,7 @@ import { constructiveUploadFieldDefinitions } from '../upload-resolver';
3638
* - PostGIS support (geometry/geography types, GeoJSON scalar — auto-detects PostGIS extension)
3739
* - PostGIS connection filter operators (spatial filtering on geometry/geography columns)
3840
* - Upload plugin (file upload to S3/MinIO for image, upload, attachment domain columns)
41+
* - Presigned URL plugin (requestUploadUrl, confirmUpload mutations + downloadUrl computed field)
3942
* - SQL expression validator (validates @sqlExpression columns in mutations)
4043
* - PG type mappings (maps custom types like email, url to GraphQL scalars)
4144
* - pgvector search (auto-discovers vector columns: filter fields, distance computed fields,
@@ -83,6 +86,7 @@ export const ConstructivePreset: GraphileConfig.Preset = {
8386
uploadFieldDefinitions: constructiveUploadFieldDefinitions,
8487
maxFileSize: 10 * 1024 * 1024, // 10MB
8588
}),
89+
PresignedUrlPreset({ s3: getPresignedUrlS3Config }),
8690
SqlExpressionValidatorPreset(),
8791
PgTypeMappingsPreset,
8892
RequiredInputPreset,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Presigned URL resolver for the Constructive presigned URL plugin.
3+
*
4+
* Reads CDN/S3 configuration from the standard env system
5+
* (getEnvOptions → pgpmDefaults + config files + env vars) and lazily
6+
* initializes an S3Client on first use.
7+
*
8+
* Follows the same lazy-init pattern as upload-resolver.ts.
9+
*/
10+
11+
import { S3Client } from '@aws-sdk/client-s3';
12+
import { getEnvOptions } from '@constructive-io/graphql-env';
13+
import { Logger } from '@pgpmjs/logger';
14+
import type { S3Config } from 'graphile-presigned-url-plugin';
15+
16+
const log = new Logger('presigned-url-resolver');
17+
18+
let s3Config: S3Config | null = null;
19+
20+
/**
21+
* Lazily initialize and return the S3Config for the presigned URL plugin.
22+
*
23+
* Reads CDN config on first call via getEnvOptions() (which already merges
24+
* pgpmDefaults → config file → env vars), creates an S3Client, and caches
25+
* the result. Same CDN config as upload-resolver.ts.
26+
*/
27+
export function getPresignedUrlS3Config(): S3Config {
28+
if (s3Config) return s3Config;
29+
30+
const { cdn } = getEnvOptions();
31+
32+
// cdn is guaranteed populated — pgpmDefaults provides all CDN fields
33+
const { bucketName, awsRegion, awsAccessKey, awsSecretKey, endpoint, publicUrlPrefix } = cdn!;
34+
35+
log.info(
36+
`[presigned-url-resolver] Initializing: bucket=${bucketName} endpoint=${endpoint}`,
37+
);
38+
39+
const client = new S3Client({
40+
region: awsRegion,
41+
credentials: { accessKeyId: awsAccessKey!, secretAccessKey: awsSecretKey! },
42+
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
43+
});
44+
45+
s3Config = {
46+
client,
47+
bucket: bucketName!,
48+
region: awsRegion,
49+
publicUrlPrefix,
50+
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
51+
};
52+
53+
return s3Config;
54+
}

0 commit comments

Comments
 (0)