diff --git a/.changeset/bright-squids-care.md b/.changeset/bright-squids-care.md new file mode 100644 index 00000000..0c29eff9 --- /dev/null +++ b/.changeset/bright-squids-care.md @@ -0,0 +1,9 @@ +--- +'typesync-cli': minor +--- + +Add full Firestore `bytes` support across Typesync. + +- support `bytes` in schema definitions, schema conversion, validation, and Zod generation +- generate the correct platform-specific bytes types for TypeScript, Python, Swift, and Firestore Rules +- add emulator-backed integration coverage for TypeScript, Python, and Swift bytes round-trips diff --git a/docs/schema/types.mdx b/docs/schema/types.mdx index 0a617fb0..86915a20 100644 --- a/docs/schema/types.mdx +++ b/docs/schema/types.mdx @@ -263,6 +263,39 @@ function isValidExample(data) { +## `bytes` + +Represents a Firestore bytes value. + + + +```yaml definition.yml +Example: + model: alias + type: bytes +``` + +```ts models.ts +// Firebase Web targets emit firestore.Bytes; React Native Firebase emits firestore.Blob. +export type Example = Buffer; +``` + +```swift models.swift +typealias Example = Data +``` + +```python models.py +Example = bytes +``` + +```javascript firestore.rules +function isValidExample(data) { + return (data is bytes); +} +``` + + + ## `literal` Represents a literal type. diff --git a/scripts/integration-test.ts b/scripts/integration-test.ts index 71762c55..67f5e56b 100644 --- a/scripts/integration-test.ts +++ b/scripts/integration-test.ts @@ -39,6 +39,26 @@ interface PlatformConfig { generatedDir: string; generatedExtension: string; generate: (definitionPath: string, outFile: string) => Promise; + /** + * Optional follow-up generation passes. Used by TypeScript to additionally + * emit code against alternative SDK targets (e.g. the web SDK) so that + * cross-target features like the `bytes` primitive can be round-tripped + * with each target's native representation. + */ + extraGenerations?: { + /** + * Subdirectory under `generatedDir` that the extra pass writes to. The + * platform's `tsconfig.json` / `Package.swift` etc. must already include + * files in this directory. + */ + subdir: string; + /** + * Restricts the pass to a subset of fixtures (matched by basename, e.g. + * `'secrets'`). Omit to apply to every fixture. + */ + onlyFixtures?: string[]; + generate: (definitionPath: string, outFile: string) => Promise; + }[]; runs: { description: string; cwd: string; cmd: string; args: string[]; underEmulator: boolean }[]; } @@ -94,6 +114,30 @@ const PLATFORMS: Record = { objectTypeFormat: 'interface', }); }, + // The `bytes` scenario is the only place where the wire-level + // representation differs across TS targets (Buffer vs firestore.Bytes + // vs firestore.Blob), so we emit it against the web SDK in addition + // to the admin SDK. The admin pass above writes `generated/secrets.ts`; + // this pass writes `generated/web/secrets.ts`, which is imported by + // the dedicated `secrets.web.test.ts` round-trip suite. The + // react-native-firebase target is verified by the unit/snapshot tests + // under `src/renderers/ts/__tests__/`; we don't run it here because + // `@react-native-firebase/firestore` is RN-runtime-only and cannot + // execute under Node. + extraGenerations: [ + { + subdir: 'web', + onlyFixtures: ['secrets'], + async generate(definition, outFile) { + await typesync.generateTs({ + definition, + outFile, + target: 'firebase@10', + objectTypeFormat: 'interface', + }); + }, + }, + ], runs: [ { description: 'tsc --noEmit (compile-time check)', @@ -129,9 +173,14 @@ function listSchemaFixtures(): { path: string; name: string }[] { function clearGeneratedDir(generatedDir: string, extension: string): void { if (!existsSync(generatedDir)) return; - for (const entry of readdirSync(generatedDir)) { - if (entry.endsWith(extension)) { - rmSync(resolve(generatedDir, entry)); + for (const entry of readdirSync(generatedDir, { withFileTypes: true })) { + const entryPath = resolve(generatedDir, entry.name); + if (entry.isDirectory()) { + clearGeneratedDir(entryPath, extension); + continue; + } + if (entry.name.endsWith(extension)) { + rmSync(entryPath); } } } @@ -139,11 +188,20 @@ function clearGeneratedDir(generatedDir: string, extension: string): void { async function generateAll(platform: Platform, config: PlatformConfig): Promise { console.log(`\n[${platform}] generating fixtures…`); clearGeneratedDir(config.generatedDir, config.generatedExtension); - for (const fixture of listSchemaFixtures()) { + const fixtures = listSchemaFixtures(); + for (const fixture of fixtures) { const outFile = resolve(config.generatedDir, `${fixture.name}${config.generatedExtension}`); await config.generate(fixture.path, outFile); console.log(` → ${fixture.name} -> ${outFile}`); } + for (const extra of config.extraGenerations ?? []) { + const targets = extra.onlyFixtures ? fixtures.filter(f => extra.onlyFixtures!.includes(f.name)) : fixtures; + for (const fixture of targets) { + const outFile = resolve(config.generatedDir, extra.subdir, `${fixture.name}${config.generatedExtension}`); + await extra.generate(fixture.path, outFile); + console.log(` → [${extra.subdir}] ${fixture.name} -> ${outFile}`); + } + } } const FIREBASE_BIN = resolve(REPO_ROOT, 'node_modules/.bin/firebase'); diff --git a/src/converters/definition-to-schema.ts b/src/converters/definition-to-schema.ts index 85a55351..62bf386d 100644 --- a/src/converters/definition-to-schema.ts +++ b/src/converters/definition-to-schema.ts @@ -20,6 +20,8 @@ export function primitiveTypeToSchema(t: definition.types.Primitive): schema.typ return { type: 'double' }; case 'timestamp': return { type: 'timestamp' }; + case 'bytes': + return { type: 'bytes' }; default: assertNever(t); } diff --git a/src/core/zod/__tests__/build-zod-schema.test.ts b/src/core/zod/__tests__/build-zod-schema.test.ts index 1bc5ecd7..e2cd411f 100644 --- a/src/core/zod/__tests__/build-zod-schema.test.ts +++ b/src/core/zod/__tests__/build-zod-schema.test.ts @@ -18,6 +18,7 @@ describe('buildZodSchemaMap()', () => { DoubleAlias: { model: 'alias', type: 'double' }, BoolAlias: { model: 'alias', type: 'boolean' }, TimestampAlias: { model: 'alias', type: 'timestamp' }, + BytesAlias: { model: 'alias', type: 'bytes' }, NilAlias: { model: 'alias', type: 'nil' }, AnyAlias: { model: 'alias', type: 'any' }, UnknownAlias: { model: 'alias', type: 'unknown' }, @@ -46,6 +47,12 @@ describe('buildZodSchemaMap()', () => { expect(getSchemaForModel(s, 'TimestampAlias').safeParse(new Date()).success).toBe(false); expect(getSchemaForModel(s, 'TimestampAlias').safeParse('2020-01-01').success).toBe(false); }); + + it('accepts only Buffer instances for bytes', () => { + expect(getSchemaForModel(s, 'BytesAlias').safeParse(Buffer.from('hello')).success).toBe(true); + expect(getSchemaForModel(s, 'BytesAlias').safeParse(new Uint8Array([1, 2, 3])).success).toBe(false); + expect(getSchemaForModel(s, 'BytesAlias').safeParse('hello').success).toBe(false); + }); }); describe('enums and literals', () => { diff --git a/src/core/zod/_emitter.ts b/src/core/zod/_emitter.ts index 4d47b51f..4fd64444 100644 --- a/src/core/zod/_emitter.ts +++ b/src/core/zod/_emitter.ts @@ -23,6 +23,7 @@ export interface ZodEmitter { int(): TOut; double(): TOut; timestamp(): TOut; + bytes(): TOut; stringLiteral(value: string): TOut; intLiteral(value: number): TOut; diff --git a/src/core/zod/_runtime-emitter.ts b/src/core/zod/_runtime-emitter.ts index fa809903..1c78f8a8 100644 --- a/src/core/zod/_runtime-emitter.ts +++ b/src/core/zod/_runtime-emitter.ts @@ -27,6 +27,7 @@ export function createRuntimeZodEmitter(registry: RuntimeZodRegistry): ZodEmitte int: () => z.number().int(), double: () => z.number(), timestamp: () => z.instanceof(Timestamp), + bytes: () => z.instanceof(Buffer), stringLiteral: value => z.literal(value), intLiteral: value => z.literal(value), diff --git a/src/core/zod/build-zod-schema.ts b/src/core/zod/build-zod-schema.ts index 02ff021c..fa9e9fe2 100644 --- a/src/core/zod/build-zod-schema.ts +++ b/src/core/zod/build-zod-schema.ts @@ -29,6 +29,8 @@ export function buildZodFromType(type: schema.types.Type, emitter: ZodEmit return emitter.double(); case 'timestamp': return emitter.timestamp(); + case 'bytes': + return emitter.bytes(); case 'string-literal': return emitter.stringLiteral(type.value); case 'int-literal': diff --git a/src/definition/_guards.ts b/src/definition/_guards.ts index f73ca93a..be684aa7 100644 --- a/src/definition/_guards.ts +++ b/src/definition/_guards.ts @@ -12,6 +12,7 @@ export function isPrimitiveType(candidate: unknown): candidate is types.Primitiv case 'int': case 'double': case 'timestamp': + case 'bytes': return true; default: assertNeverNoThrow(c); diff --git a/src/definition/impl/__tests__/consistent.ts b/src/definition/impl/__tests__/consistent.ts index 42659b64..1865018e 100644 --- a/src/definition/impl/__tests__/consistent.ts +++ b/src/definition/impl/__tests__/consistent.ts @@ -8,6 +8,7 @@ import { anyType, booleanLiteralType, booleanType, + bytesType, definition, discriminatedUnionType, documentModel, @@ -86,6 +87,12 @@ type IsExact = [Required] extends [Required] ? ([Required] extend assertEmpty>(true); })(); +(() => { + type DeclaredType = types.Bytes; + type InferredType = z.infer; + assertEmpty>(true); +})(); + (() => { type DeclaredType = types.Primitive; type InferredType = z.infer; diff --git a/src/definition/impl/_zod-schemas.ts b/src/definition/impl/_zod-schemas.ts index c8c1b0bc..71a9eb5a 100644 --- a/src/definition/impl/_zod-schemas.ts +++ b/src/definition/impl/_zod-schemas.ts @@ -18,8 +18,10 @@ export const doubleType = z.literal('double').describe('A double type.'); export const timestampType = z.literal('timestamp').describe('A timestamp type.'); +export const bytesType = z.literal('bytes').describe('A bytes type.'); + export const primitiveType = z - .union([anyType, unknownType, nilType, stringType, booleanType, intType, doubleType, timestampType]) + .union([anyType, unknownType, nilType, stringType, booleanType, intType, doubleType, timestampType, bytesType]) .describe('A primitive type'); export const stringLiteralType = z diff --git a/src/definition/types/_types.ts b/src/definition/types/_types.ts index c724c891..4878dcdb 100644 --- a/src/definition/types/_types.ts +++ b/src/definition/types/_types.ts @@ -14,7 +14,9 @@ export type Double = 'double'; export type Timestamp = 'timestamp'; -export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp; +export type Bytes = 'bytes'; + +export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes; export interface StringLiteral { type: 'literal'; diff --git a/src/generators/python/__tests__/generator.test.ts b/src/generators/python/__tests__/generator.test.ts index 447951b6..7aec1c8a 100644 --- a/src/generators/python/__tests__/generator.test.ts +++ b/src/generators/python/__tests__/generator.test.ts @@ -21,6 +21,7 @@ describe('PythonGeneratorImpl', () => { IntAlias: { model: 'alias', type: 'int' }, DoubleAlias: { model: 'alias', type: 'double' }, TimestampAlias: { model: 'alias', type: 'timestamp' }, + BytesAlias: { model: 'alias', type: 'bytes' }, }); const generation = createGenerator().generate(s); @@ -34,6 +35,7 @@ describe('PythonGeneratorImpl', () => { IntAlias: { type: 'int' }, DoubleAlias: { type: 'float' }, TimestampAlias: { type: 'datetime' }, + BytesAlias: { type: 'bytes' }, }; expect(generation.declarations).toHaveLength(Object.keys(expectedPythonTypeByModelName).length); diff --git a/src/generators/python/_adjust-schema.ts b/src/generators/python/_adjust-schema.ts index 72754332..042b50b7 100644 --- a/src/generators/python/_adjust-schema.ts +++ b/src/generators/python/_adjust-schema.ts @@ -114,6 +114,7 @@ export function adjustSchemaForPython(prevSchema: schema.Schema): schema.python. case 'int': case 'double': case 'timestamp': + case 'bytes': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/python/_converters.ts b/src/generators/python/_converters.ts index 94144fc7..267e491a 100644 --- a/src/generators/python/_converters.ts +++ b/src/generators/python/_converters.ts @@ -34,6 +34,10 @@ export function timestampTypeToPython(_t: schema.python.types.Timestamp): python return { type: 'datetime' }; } +export function bytesTypeToPython(_t: schema.python.types.Bytes): python.Bytes { + return { type: 'bytes' }; +} + export function literalTypeToPython(t: schema.python.types.Literal): python.Literal { return { type: 'literal', value: t.value }; } @@ -82,6 +86,8 @@ export function flatTypeToPython(t: schema.python.types.Type): python.Type { return doubleTypeToPython(t); case 'timestamp': return timestampTypeToPython(t); + case 'bytes': + return bytesTypeToPython(t); case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/python/_impl.ts b/src/generators/python/_impl.ts index 71b257cb..3b83d184 100644 --- a/src/generators/python/_impl.ts +++ b/src/generators/python/_impl.ts @@ -41,6 +41,7 @@ class PythonGeneratorImpl implements PythonGenerator { case 'int': case 'double': case 'timestamp': + case 'bytes': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/rules/__tests__/generator.test.ts b/src/generators/rules/__tests__/generator.test.ts index 307a343c..3ed60254 100644 --- a/src/generators/rules/__tests__/generator.test.ts +++ b/src/generators/rules/__tests__/generator.test.ts @@ -28,6 +28,7 @@ describe('RulesGeneratorImpl', () => { AInt: { model: 'alias', type: 'int' }, ADouble: { model: 'alias', type: 'double' }, ATs: { model: 'alias', type: 'timestamp' }, + ABytes: { model: 'alias', type: 'bytes' }, }); const generation = createGenerator().generate(s); @@ -38,6 +39,7 @@ describe('RulesGeneratorImpl', () => { AInt: { type: 'int' }, ADouble: { type: 'number' }, ATs: { type: 'timestamp' }, + ABytes: { type: 'bytes' }, }; expect(generation.typeValidatorDeclarations).toHaveLength(Object.keys(expectedRulesTypeByModelName).length); diff --git a/src/generators/rules/_adjust-schema.ts b/src/generators/rules/_adjust-schema.ts index c93ab4a9..3e5b5d5e 100644 --- a/src/generators/rules/_adjust-schema.ts +++ b/src/generators/rules/_adjust-schema.ts @@ -74,6 +74,7 @@ export function adjustSchemaForRules(prevSchema: schema.Schema): schema.rules.Sc case 'int': case 'double': case 'timestamp': + case 'bytes': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/rules/_converters.ts b/src/generators/rules/_converters.ts index a35ff406..71221374 100644 --- a/src/generators/rules/_converters.ts +++ b/src/generators/rules/_converters.ts @@ -34,6 +34,10 @@ export function timestampTypeToRules(_t: schema.rules.types.Timestamp): rules.Ti return { type: 'timestamp' }; } +export function bytesTypeToRules(_t: schema.rules.types.Bytes): rules.Bytes { + return { type: 'bytes' }; +} + export function stringLiteralTypeToRules(t: schema.rules.types.StringLiteral): rules.Literal { return { type: 'literal', value: t.value }; } @@ -121,6 +125,8 @@ export function flatTypeToRules(t: schema.rules.types.Type): rules.Type { return doubleTypeToRules(t); case 'timestamp': return timestampTypeToRules(t); + case 'bytes': + return bytesTypeToRules(t); case 'string-literal': return stringLiteralTypeToRules(t); case 'int-literal': diff --git a/src/generators/rules/_has-readonly-field.ts b/src/generators/rules/_has-readonly-field.ts index 84248a1a..e229499a 100644 --- a/src/generators/rules/_has-readonly-field.ts +++ b/src/generators/rules/_has-readonly-field.ts @@ -11,6 +11,7 @@ export function typeHasReadonlyField(t: schema.rules.types.Type, adjustedSchema: case 'int': case 'double': case 'timestamp': + case 'bytes': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/rules/_readonly-field-predicates.ts b/src/generators/rules/_readonly-field-predicates.ts index 9037ce7a..3fbcf5bc 100644 --- a/src/generators/rules/_readonly-field-predicates.ts +++ b/src/generators/rules/_readonly-field-predicates.ts @@ -26,6 +26,7 @@ export function readonlyFieldPredicateForType( case 'int': case 'double': case 'timestamp': + case 'bytes': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/rules/_type-predicates.ts b/src/generators/rules/_type-predicates.ts index 41e6a573..a73a7ad1 100644 --- a/src/generators/rules/_type-predicates.ts +++ b/src/generators/rules/_type-predicates.ts @@ -29,6 +29,10 @@ export function typePredicateForTimestampType(t: rules.Timestamp, varName: strin return { type: 'type-equality', varName, varType: t }; } +export function typePredicateForBytesType(t: rules.Bytes, varName: string): rules.Predicate { + return { type: 'type-equality', varName, varType: t }; +} + export function typePredicateForLiteralType(t: rules.Literal, varName: string): rules.Predicate { return { type: 'value-equality', varName, varValue: typeof t.value === 'string' ? `'${t.value}'` : `${t.value}` }; } @@ -168,6 +172,8 @@ export function typePredicateForType(t: rules.Type, varName: string, ctx: Contex return typePredicateForNumberType(t, varName); case 'timestamp': return typePredicateForTimestampType(t, varName); + case 'bytes': + return typePredicateForBytesType(t, varName); case 'literal': return typePredicateForLiteralType(t, varName); case 'enum': diff --git a/src/generators/swift/__tests__/generator.test.ts b/src/generators/swift/__tests__/generator.test.ts index 9c0b2b1f..3d398dfb 100644 --- a/src/generators/swift/__tests__/generator.test.ts +++ b/src/generators/swift/__tests__/generator.test.ts @@ -25,6 +25,7 @@ describe('SwiftGeneratorImpl', () => { IntAlias: { model: 'alias', type: 'int' }, DoubleAlias: { model: 'alias', type: 'double' }, TimestampAlias: { model: 'alias', type: 'timestamp' }, + BytesAlias: { model: 'alias', type: 'bytes' }, }); const generation = createGenerator().generate(s); @@ -38,6 +39,7 @@ describe('SwiftGeneratorImpl', () => { IntAlias: { type: 'int' }, DoubleAlias: { type: 'double' }, TimestampAlias: { type: 'date' }, + BytesAlias: { type: 'data' }, }; expect(generation.declarations).toHaveLength(Object.keys(expectedSwiftTypeByModelName).length); diff --git a/src/generators/swift/_adjust-schema.ts b/src/generators/swift/_adjust-schema.ts index 27f569d1..e2e44c1b 100644 --- a/src/generators/swift/_adjust-schema.ts +++ b/src/generators/swift/_adjust-schema.ts @@ -114,6 +114,7 @@ export function adjustSchemaForSwift(prevSchema: schema.Schema): schema.swift.Sc case 'int': case 'double': case 'timestamp': + case 'bytes': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/swift/_converters.ts b/src/generators/swift/_converters.ts index 45deb493..111ef8f6 100644 --- a/src/generators/swift/_converters.ts +++ b/src/generators/swift/_converters.ts @@ -34,6 +34,10 @@ export function timestampTypeToSwift(_t: schema.swift.types.Timestamp): swift.Da return { type: 'date' }; } +export function bytesTypeToSwift(_t: schema.swift.types.Bytes): swift.Data { + return { type: 'data' }; +} + export function stringLiteralTypeToSwift(_t: schema.swift.types.StringLiteral): swift.String { return { type: 'string' }; } @@ -93,6 +97,8 @@ export function flatTypeToSwift(t: schema.swift.types.Type): swift.Type { return doubleTypeToSwift(t); case 'timestamp': return timestampTypeToSwift(t); + case 'bytes': + return bytesTypeToSwift(t); case 'string-literal': return stringLiteralTypeToSwift(t); case 'int-literal': diff --git a/src/generators/swift/_impl.ts b/src/generators/swift/_impl.ts index e139fa5b..23aca336 100644 --- a/src/generators/swift/_impl.ts +++ b/src/generators/swift/_impl.ts @@ -62,6 +62,7 @@ class SwiftGeneratorImpl implements SwiftGenerator { case 'int': case 'double': case 'timestamp': + case 'bytes': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/ts/__tests__/generator.test.ts b/src/generators/ts/__tests__/generator.test.ts index 44774d21..0822914b 100644 --- a/src/generators/ts/__tests__/generator.test.ts +++ b/src/generators/ts/__tests__/generator.test.ts @@ -22,6 +22,7 @@ describe('TSGeneratorImpl', () => { IntAlias: { model: 'alias', type: 'int' }, DoubleAlias: { model: 'alias', type: 'double' }, TimestampAlias: { model: 'alias', type: 'timestamp' }, + BytesAlias: { model: 'alias', type: 'bytes' }, }); const generation = createGenerator().generate(s); @@ -35,6 +36,7 @@ describe('TSGeneratorImpl', () => { IntAlias: { type: 'number' }, DoubleAlias: { type: 'number' }, TimestampAlias: { type: 'timestamp' }, + BytesAlias: { type: 'bytes' }, }; expect(generation.declarations).toHaveLength(Object.keys(expectedTsTypeByModelName).length); diff --git a/src/generators/ts/_converters.ts b/src/generators/ts/_converters.ts index ba67f036..886efbe0 100644 --- a/src/generators/ts/_converters.ts +++ b/src/generators/ts/_converters.ts @@ -34,6 +34,10 @@ export function timestampTypeToTS(_t: schema.ts.types.Timestamp): ts.Timestamp { return { type: 'timestamp' }; } +export function bytesTypeToTS(_t: schema.ts.types.Bytes): ts.Bytes { + return { type: 'bytes' }; +} + export function stringLiteralTypeToTS(t: schema.ts.types.StringLiteral): ts.Literal { return { type: 'literal', value: t.value }; } @@ -104,6 +108,8 @@ export function typeToTS(t: schema.ts.types.Type): ts.Type { return doubleTypeToTS(t); case 'timestamp': return timestampTypeToTS(t); + case 'bytes': + return bytesTypeToTS(t); case 'string-literal': return stringLiteralTypeToTS(t); case 'int-literal': diff --git a/src/platforms/python/_expressions.ts b/src/platforms/python/_expressions.ts index 2895c300..4e8ee944 100644 --- a/src/platforms/python/_expressions.ts +++ b/src/platforms/python/_expressions.ts @@ -4,6 +4,7 @@ import type { Alias, Any, Bool, + Bytes, Datetime, Dict, DiscriminatedUnion, @@ -55,6 +56,10 @@ export function expressionForDatetimeType(_t: Datetime): Expression { return { content: 'datetime.datetime' }; } +export function expressionForBytesType(_t: Bytes): Expression { + return { content: 'bytes' }; +} + export function expressionForLiteralType(t: Literal): Expression { switch (typeof t.value) { case 'string': @@ -117,6 +122,8 @@ export function expressionForType(t: Type): Expression { return expressionForFloatType(t); case 'datetime': return expressionForDatetimeType(t); + case 'bytes': + return expressionForBytesType(t); case 'literal': return expressionForLiteralType(t); case 'tuple': diff --git a/src/platforms/python/_guards.ts b/src/platforms/python/_guards.ts index 0d61ffc4..db0206ba 100644 --- a/src/platforms/python/_guards.ts +++ b/src/platforms/python/_guards.ts @@ -9,6 +9,7 @@ export function isPrimitiveType(t: Type): t is Primitive { case 'str': case 'bool': case 'datetime': + case 'bytes': case 'int': case 'float': return true; diff --git a/src/platforms/python/_types.ts b/src/platforms/python/_types.ts index e12c6fd4..1dbbe2fa 100644 --- a/src/platforms/python/_types.ts +++ b/src/platforms/python/_types.ts @@ -30,7 +30,11 @@ export interface Datetime { readonly type: 'datetime'; } -export type Primitive = Undefined | Any | None | Str | Bool | Int | Float | Datetime; +export interface Bytes { + readonly type: 'bytes'; +} + +export type Primitive = Undefined | Any | None | Str | Bool | Int | Float | Datetime | Bytes; export interface Literal { readonly type: 'literal'; diff --git a/src/platforms/rules/_guards.ts b/src/platforms/rules/_guards.ts index 141a1c71..1c15b8db 100644 --- a/src/platforms/rules/_guards.ts +++ b/src/platforms/rules/_guards.ts @@ -9,6 +9,7 @@ export function isRulesDataType(t: Type): t is RulesDataType { case 'int': case 'number': case 'timestamp': + case 'bytes': case 'list': case 'map': return true; diff --git a/src/platforms/rules/_types.ts b/src/platforms/rules/_types.ts index eb140d20..a26c886b 100644 --- a/src/platforms/rules/_types.ts +++ b/src/platforms/rules/_types.ts @@ -26,7 +26,11 @@ export interface Timestamp { readonly type: 'timestamp'; } -export type Primitive = Any | String | Bool | Float | Int | Number | Timestamp; +export interface Bytes { + readonly type: 'bytes'; +} + +export type Primitive = Any | String | Bool | Float | Int | Number | Timestamp | Bytes; export interface Literal { readonly type: 'literal'; @@ -83,4 +87,4 @@ export interface Alias { export type Type = Primitive | Literal | Enum | Tuple | List | Map | Object | DiscriminatedUnion | SimpleUnion | Alias; -export type RulesDataType = String | Bool | Float | Int | Number | Timestamp | List | Map; +export type RulesDataType = String | Bool | Float | Int | Number | Timestamp | Bytes | List | Map; diff --git a/src/platforms/swift/_expressions.ts b/src/platforms/swift/_expressions.ts index a8af1760..2f65a8e9 100644 --- a/src/platforms/swift/_expressions.ts +++ b/src/platforms/swift/_expressions.ts @@ -1,5 +1,19 @@ import { assertNever } from '../../util/assert.js'; -import type { Alias, Any, Bool, Date, Dictionary, Double, Int, List, Nil, String, Tuple, Type } from './_types.js'; +import type { + Alias, + Any, + Bool, + Data, + Date, + Dictionary, + Double, + Int, + List, + Nil, + String, + Tuple, + Type, +} from './_types.js'; export interface Expression { content: string; @@ -33,6 +47,10 @@ export function expressionForDateType(_t: Date): Expression { return { content: 'Date' }; } +export function expressionForDataType(_t: Data): Expression { + return { content: 'Data' }; +} + export function expressionForTupleType(t: Tuple): Expression { const commaSeparatedExpressions = t.elements.map(vt => expressionForType(vt).content).join(', '); return { content: `(${commaSeparatedExpressions})` }; @@ -68,6 +86,8 @@ export function expressionForType(t: Type): Expression { return expressionForDoubleType(t); case 'date': return expressionForDateType(t); + case 'data': + return expressionForDataType(t); case 'tuple': return expressionForTupleType(t); case 'list': diff --git a/src/platforms/swift/_guards.ts b/src/platforms/swift/_guards.ts index 41d6ed60..ccf01c71 100644 --- a/src/platforms/swift/_guards.ts +++ b/src/platforms/swift/_guards.ts @@ -10,6 +10,7 @@ export function isPrimitiveType(t: Type): t is Primitive { case 'int': case 'double': case 'date': + case 'data': return true; case 'tuple': case 'list': diff --git a/src/platforms/swift/_types.ts b/src/platforms/swift/_types.ts index 86d6d253..4cfb3244 100644 --- a/src/platforms/swift/_types.ts +++ b/src/platforms/swift/_types.ts @@ -26,7 +26,11 @@ export interface Date { readonly type: 'date'; } -export type Primitive = Any | Nil | String | Bool | Int | Double | Date; +export interface Data { + readonly type: 'data'; +} + +export type Primitive = Any | Nil | String | Bool | Int | Double | Date | Data; export interface Tuple { readonly type: 'tuple'; diff --git a/src/platforms/ts/_expressions.ts b/src/platforms/ts/_expressions.ts index a49bb656..63e9964b 100644 --- a/src/platforms/ts/_expressions.ts +++ b/src/platforms/ts/_expressions.ts @@ -1,10 +1,12 @@ import { StringBuilder } from '@proficient/ds'; +import type { TSGenerationTarget } from '../../api/index.js'; import { assertNever } from '../../util/assert.js'; import type { Alias, Any, Boolean, + Bytes, Enum, List, Literal, @@ -24,6 +26,19 @@ export interface Expression { content: string; } +/** + * Per-call context threaded through `expressionForType` and friends. + * + * The TypeScript output of a few primitive types depends on which Firebase + * SDK the caller is generating against (e.g. `bytes` becomes `Buffer` for + * the admin SDK, `firestore.Bytes` for the web SDK, and `firestore.Blob` + * for the React Native SDK), so each expression-emitter takes the active + * generation target rather than guessing a default. + */ +export interface ExpressionOptions { + target: TSGenerationTarget; +} + export function expressionForAnyType(_t: Any): Expression { return { content: 'any' }; } @@ -52,6 +67,26 @@ export function expressionForTimestampType(_t: Timestamp): Expression { return { content: 'firestore.Timestamp' }; } +export function expressionForBytesType(_t: Bytes, options: ExpressionOptions): Expression { + switch (options.target) { + case 'firebase-admin@13': + case 'firebase-admin@12': + case 'firebase-admin@11': + case 'firebase-admin@10': + return { content: 'Buffer' }; + case 'firebase@11': + case 'firebase@10': + case 'firebase@9': + return { content: 'firestore.Bytes' }; + case 'react-native-firebase@21': + case 'react-native-firebase@20': + case 'react-native-firebase@19': + return { content: 'firestore.Blob' }; + default: + assertNever(options.target); + } +} + export function expressionForLiteralType(t: Literal): Expression { switch (typeof t.value) { case 'string': @@ -82,22 +117,22 @@ export function expressionForEnumType(t: Enum): Expression { return { content }; } -export function expressionForTupleType(t: Tuple): Expression { - const commaSeparatedExpressions = t.elements.map(vt => expressionForType(vt).content).join(', '); +export function expressionForTupleType(t: Tuple, options: ExpressionOptions): Expression { + const commaSeparatedExpressions = t.elements.map(vt => expressionForType(vt, options).content).join(', '); return { content: `[${commaSeparatedExpressions}]` }; } -export function expressionForListType(t: List): Expression { - const expression = expressionForType(t.elementType); +export function expressionForListType(t: List, options: ExpressionOptions): Expression { + const expression = expressionForType(t.elementType, options); return { content: `${expression.content}[]` }; } -export function expressionForRecordType(t: Record): Expression { - const expression = expressionForType(t.valueType); +export function expressionForRecordType(t: Record, options: ExpressionOptions): Expression { + const expression = expressionForType(t.valueType, options); return { content: `Record` }; } -export function expressionForObjectType(t: Object): Expression { +export function expressionForObjectType(t: Object, options: ExpressionOptions): Expression { const { properties, additionalProperties } = t; const b = new StringBuilder(); @@ -106,7 +141,7 @@ export function expressionForObjectType(t: Object): Expression { if (prop.docs !== null) { b.append(`/** ${prop.docs} */\n`); } - const expression = expressionForType(prop.type); + const expression = expressionForType(prop.type, options); b.append(`${prop.name}${prop.optional ? '?' : ''}: ${expression.content};\n`); }); if (additionalProperties) { @@ -116,8 +151,8 @@ export function expressionForObjectType(t: Object): Expression { return { content: b.toString() }; } -export function expressionForUnionType(t: Union): Expression { - const separatedExpressions = t.variants.map(vt => expressionForType(vt).content).join(' | '); +export function expressionForUnionType(t: Union, options: ExpressionOptions): Expression { + const separatedExpressions = t.variants.map(vt => expressionForType(vt, options).content).join(' | '); return { content: `${separatedExpressions}` }; } @@ -125,7 +160,7 @@ export function expressionForAliasType(t: Alias): Expression { return { content: t.name }; } -export function expressionForType(t: Type): Expression { +export function expressionForType(t: Type, options: ExpressionOptions): Expression { switch (t.type) { case 'any': return expressionForAnyType(t); @@ -141,20 +176,22 @@ export function expressionForType(t: Type): Expression { return expressionForNumberType(t); case 'timestamp': return expressionForTimestampType(t); + case 'bytes': + return expressionForBytesType(t, options); case 'literal': return expressionForLiteralType(t); case 'enum': return expressionForEnumType(t); case 'tuple': - return expressionForTupleType(t); + return expressionForTupleType(t, options); case 'list': - return expressionForListType(t); + return expressionForListType(t, options); case 'record': - return expressionForRecordType(t); + return expressionForRecordType(t, options); case 'object': - return expressionForObjectType(t); + return expressionForObjectType(t, options); case 'union': - return expressionForUnionType(t); + return expressionForUnionType(t, options); case 'alias': return expressionForAliasType(t); default: diff --git a/src/platforms/ts/_guards.ts b/src/platforms/ts/_guards.ts index b0d77544..db4e0953 100644 --- a/src/platforms/ts/_guards.ts +++ b/src/platforms/ts/_guards.ts @@ -10,6 +10,7 @@ export function isPrimitiveType(t: Type): t is Primitive { case 'boolean': case 'number': case 'timestamp': + case 'bytes': return true; case 'literal': case 'enum': diff --git a/src/platforms/ts/_types.ts b/src/platforms/ts/_types.ts index 14fc7b2d..675cf1bb 100644 --- a/src/platforms/ts/_types.ts +++ b/src/platforms/ts/_types.ts @@ -26,7 +26,11 @@ export interface Timestamp { readonly type: 'timestamp'; } -export type Primitive = Any | Unknown | Null | String | Boolean | Number | Timestamp; +export interface Bytes { + readonly type: 'bytes'; +} + +export type Primitive = Any | Unknown | Null | String | Boolean | Number | Timestamp | Bytes; export interface Literal { readonly type: 'literal'; diff --git a/src/renderers/python/__tests__/__file_snapshots__/all-type-expressions.py b/src/renderers/python/__tests__/__file_snapshots__/all-type-expressions.py index a2bbd2a6..5d86c136 100644 --- a/src/renderers/python/__tests__/__file_snapshots__/all-type-expressions.py +++ b/src/renderers/python/__tests__/__file_snapshots__/all-type-expressions.py @@ -63,6 +63,8 @@ def model_dump(self, **kwargs) -> typing.Dict[str, typing.Any]: CreatedAt = datetime.datetime +Avatar = bytes + TheAnswer = typing.Literal[42] Coords = tuple[int, int] diff --git a/src/renderers/python/__tests__/renderer.test.ts b/src/renderers/python/__tests__/renderer.test.ts index 82bb7dbe..e3b4c7a2 100644 --- a/src/renderers/python/__tests__/renderer.test.ts +++ b/src/renderers/python/__tests__/renderer.test.ts @@ -30,6 +30,7 @@ describe('PythonRendererImpl', () => { { type: 'alias', modelName: 'Age', modelType: { type: 'int' }, modelDocs: null }, { type: 'alias', modelName: 'Pi', modelType: { type: 'float' }, modelDocs: null }, { type: 'alias', modelName: 'CreatedAt', modelType: { type: 'datetime' }, modelDocs: null }, + { type: 'alias', modelName: 'Avatar', modelType: { type: 'bytes' }, modelDocs: null }, { type: 'alias', modelName: 'TheAnswer', modelType: { type: 'literal', value: 42 }, modelDocs: null }, { type: 'alias', diff --git a/src/renderers/swift/__tests__/__file_snapshots__/all-type-expressions.swift b/src/renderers/swift/__tests__/__file_snapshots__/all-type-expressions.swift index 10da6b7c..b2447621 100644 --- a/src/renderers/swift/__tests__/__file_snapshots__/all-type-expressions.swift +++ b/src/renderers/swift/__tests__/__file_snapshots__/all-type-expressions.swift @@ -13,6 +13,8 @@ typealias Pi = Double typealias CreatedAt = Date +typealias Avatar = Data + typealias Coords = (Int, Int) typealias Tags = [String] diff --git a/src/renderers/swift/__tests__/renderer.test.ts b/src/renderers/swift/__tests__/renderer.test.ts index c6bb8a15..3f6eda43 100644 --- a/src/renderers/swift/__tests__/renderer.test.ts +++ b/src/renderers/swift/__tests__/renderer.test.ts @@ -21,6 +21,7 @@ describe('SwiftRendererImpl', () => { { type: 'typealias', modelName: 'Age', modelType: { type: 'int' }, modelDocs: null }, { type: 'typealias', modelName: 'Pi', modelType: { type: 'double' }, modelDocs: null }, { type: 'typealias', modelName: 'CreatedAt', modelType: { type: 'date' }, modelDocs: null }, + { type: 'typealias', modelName: 'Avatar', modelType: { type: 'data' }, modelDocs: null }, { type: 'typealias', modelName: 'Coords', diff --git a/src/renderers/ts/__tests__/__file_snapshots__/all-type-expressions.ts b/src/renderers/ts/__tests__/__file_snapshots__/all-type-expressions.ts index 19ec8b51..de59c47a 100644 --- a/src/renderers/ts/__tests__/__file_snapshots__/all-type-expressions.ts +++ b/src/renderers/ts/__tests__/__file_snapshots__/all-type-expressions.ts @@ -15,6 +15,8 @@ export type Age = number; export type CreatedAt = firestore.Timestamp; +export type Avatar = Buffer; + export type TheAnswer = 42; export type Color = 'red' | 'blue'; diff --git a/src/renderers/ts/__tests__/renderer.test.ts b/src/renderers/ts/__tests__/renderer.test.ts index ef43cf8f..4309697b 100644 --- a/src/renderers/ts/__tests__/renderer.test.ts +++ b/src/renderers/ts/__tests__/renderer.test.ts @@ -26,6 +26,7 @@ describe('TSRendererImpl', () => { { type: 'alias', modelName: 'Active', modelType: { type: 'boolean' }, modelDocs: null }, { type: 'alias', modelName: 'Age', modelType: { type: 'number' }, modelDocs: null }, { type: 'alias', modelName: 'CreatedAt', modelType: { type: 'timestamp' }, modelDocs: null }, + { type: 'alias', modelName: 'Avatar', modelType: { type: 'bytes' }, modelDocs: null }, { type: 'alias', modelName: 'TheAnswer', modelType: { type: 'literal', value: 42 }, modelDocs: null }, { type: 'alias', @@ -162,4 +163,21 @@ describe('TSRendererImpl', () => { } } }); + + it('renders bytes using the Firestore byte type for each target family', async () => { + const generation: TSGeneration = { + type: 'ts', + declarations: [{ type: 'alias', modelName: 'Payload', modelType: { type: 'bytes' }, modelDocs: null }], + }; + + await expect((await createRenderer({ target: 'firebase-admin@13' }).render(generation)).content).toContain( + 'export type Payload = Buffer;' + ); + await expect((await createRenderer({ target: 'firebase@10' }).render(generation)).content).toContain( + 'export type Payload = firestore.Bytes;' + ); + await expect((await createRenderer({ target: 'react-native-firebase@21' }).render(generation)).content).toContain( + 'export type Payload = firestore.Blob;' + ); + }); }); diff --git a/src/renderers/ts/_impl.ts b/src/renderers/ts/_impl.ts index 5a3af7aa..ede94988 100644 --- a/src/renderers/ts/_impl.ts +++ b/src/renderers/ts/_impl.ts @@ -41,7 +41,7 @@ class TSRendererImpl implements TSRenderer { switch (declaration.type) { case 'alias': { const { modelName, modelType, modelDocs } = declaration; - const expression = ts.expressionForType(modelType); + const expression = ts.expressionForType(modelType, { target: this.config.target }); if (modelDocs !== null) { output += `/** ${modelDocs} */\n`; } @@ -50,7 +50,7 @@ class TSRendererImpl implements TSRenderer { } case 'interface': { const { modelName, modelType, modelDocs } = declaration; - const expression = ts.expressionForType(modelType); + const expression = ts.expressionForType(modelType, { target: this.config.target }); if (modelDocs !== null) { output += `/** ${modelDocs} */\n`; } diff --git a/src/schema/core/__tests__/validate-type.test.ts b/src/schema/core/__tests__/validate-type.test.ts index f341c01a..b8d8e8bb 100644 --- a/src/schema/core/__tests__/validate-type.test.ts +++ b/src/schema/core/__tests__/validate-type.test.ts @@ -66,6 +66,14 @@ describe('schema.validateType()', () => { }); }); + describe('bytes', () => { + it('does not throw if the type is valid', () => { + const schema = createSchema(); + const t: types.Bytes = { type: 'bytes' }; + expect(() => schema.validateType(t)).not.toThrow(); + }); + }); + describe('string-literal', () => { it('does not throw if the type is valid', () => { const schema = createSchema(); diff --git a/src/schema/core/_zod-schemas.ts b/src/schema/core/_zod-schemas.ts index 792060a3..f91bed2d 100644 --- a/src/schema/core/_zod-schemas.ts +++ b/src/schema/core/_zod-schemas.ts @@ -55,6 +55,12 @@ export function createZodSchemasForSchema(schema: Schema) { }) .strict(); + const bytesType = z + .object({ + type: z.literal('bytes'), + }) + .strict(); + const primitiveType = anyType .or(unknownType) .or(nilType) @@ -62,7 +68,8 @@ export function createZodSchemasForSchema(schema: Schema) { .or(booleanType) .or(intType) .or(doubleType) - .or(timestampType); + .or(timestampType) + .or(bytesType); const stringLiteralType = z .object({ @@ -381,6 +388,7 @@ export function createZodSchemasForSchema(schema: Schema) { intType, doubleType, timestampType, + bytesType, primitiveType, stringLiteralType, intLiteralType, diff --git a/src/schema/core/types.ts b/src/schema/core/types.ts index 9c2bd2d6..97e70c35 100644 --- a/src/schema/core/types.ts +++ b/src/schema/core/types.ts @@ -21,6 +21,7 @@ export { Int, Double, Timestamp, + Bytes, Primitive, StringLiteral, IntLiteral, diff --git a/src/schema/generic.ts b/src/schema/generic.ts index f732b298..e08116eb 100644 --- a/src/schema/generic.ts +++ b/src/schema/generic.ts @@ -34,7 +34,11 @@ export interface Timestamp { type: 'timestamp'; } -export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp; +export interface Bytes { + type: 'bytes'; +} + +export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes; export interface StringLiteral { type: 'string-literal'; diff --git a/src/schema/python/types.ts b/src/schema/python/types.ts index 776ca6ed..677d9523 100644 --- a/src/schema/python/types.ts +++ b/src/schema/python/types.ts @@ -20,6 +20,7 @@ export { Int, Double, Timestamp, + Bytes, Primitive, StringLiteral, IntLiteral, diff --git a/src/schema/rules/types.ts b/src/schema/rules/types.ts index 9c2bd2d6..97e70c35 100644 --- a/src/schema/rules/types.ts +++ b/src/schema/rules/types.ts @@ -21,6 +21,7 @@ export { Int, Double, Timestamp, + Bytes, Primitive, StringLiteral, IntLiteral, diff --git a/src/schema/swift/types.ts b/src/schema/swift/types.ts index 40bd9b68..7e2686d1 100644 --- a/src/schema/swift/types.ts +++ b/src/schema/swift/types.ts @@ -20,6 +20,7 @@ export { Int, Double, Timestamp, + Bytes, Primitive, StringLiteral, IntLiteral, diff --git a/src/schema/ts/types.ts b/src/schema/ts/types.ts index 9c2bd2d6..97e70c35 100644 --- a/src/schema/ts/types.ts +++ b/src/schema/ts/types.ts @@ -21,6 +21,7 @@ export { Int, Double, Timestamp, + Bytes, Primitive, StringLiteral, IntLiteral, diff --git a/tests/integration/_fixtures/samples/secrets/api-key.json b/tests/integration/_fixtures/samples/secrets/api-key.json new file mode 100644 index 00000000..8d5a3f4c --- /dev/null +++ b/tests/integration/_fixtures/samples/secrets/api-key.json @@ -0,0 +1,11 @@ +{ + "label": "primary-api-key", + "payload_base64": "SGVsbG8sIHdvcmxkIQ==", + "checksum_base64": "MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=", + "shards_base64": [ + "c2hhcmQtMQ==", + "c2hhcmQtMg==", + "AAECAwQF" + ], + "created_at": "2024-05-09T10:00:00.000Z" +} diff --git a/tests/integration/_fixtures/schemas/secrets.yml b/tests/integration/_fixtures/schemas/secrets.yml new file mode 100644 index 00000000..c706085c --- /dev/null +++ b/tests/integration/_fixtures/schemas/secrets.yml @@ -0,0 +1,41 @@ +# yaml-language-server: $schema=../../../../schema.local.json +# +# Shared integration-test fixture for the `bytes` primitive. Each platform +# consumes it differently because each Firestore SDK represents bytes with +# a platform-native type: +# +# - TypeScript (firebase-admin@13): Buffer +# - TypeScript (firebase@10, web): firestore.Bytes +# - Python (firebase-admin@6): bytes +# - Swift (firebase@10): Data +# +# The schema deliberately mixes a top-level bytes field, a second bytes +# field with different content, a list of bytes (so we exercise nested +# bytes in collection types), a sibling string and a timestamp. That is +# enough to confirm bytes round-trip alongside other primitives without +# accidental coercion (e.g. to base64 strings) on either the encode or +# decode side. + +Secret: + model: document + path: secrets/{secretId} + docs: A document storing opaque binary material alongside metadata. + type: + type: object + fields: + label: + type: string + docs: Human-readable label for the secret. + payload: + type: bytes + docs: Opaque binary blob (encrypted material, key bytes, etc.). + checksum: + type: bytes + docs: A second bytes field, e.g. a SHA-256 digest of the payload. + shards: + type: + type: list + elementType: bytes + docs: Additional bytes blobs stored as a list to exercise bytes-in-list. + created_at: + type: timestamp diff --git a/tests/integration/python/tests/test_secrets.py b/tests/integration/python/tests/test_secrets.py new file mode 100644 index 00000000..d127276e --- /dev/null +++ b/tests/integration/python/tests/test_secrets.py @@ -0,0 +1,116 @@ +"""Round-trip tests for the `secrets` fixture. + +Verifies that the `bytes` primitive emitted by the Python generator +(`bytes` / `typing.List[bytes]`) round-trips correctly through the +Firestore emulator using the official `google-cloud-firestore` client, +which is what `firebase-admin` uses underneath. The Firestore Python +client maps Python `bytes` directly to the protobuf `bytes_value` and +decodes them back into Python `bytes` on read. +""" + +from __future__ import annotations + +import base64 +import json +import uuid +from pathlib import Path + +import pytest +from google.cloud import firestore + + +def _load_sample(fixtures_root: Path, name: str) -> dict: + return json.loads((fixtures_root / "samples" / "secrets" / f"{name}.json").read_text()) + + +@pytest.fixture +def secrets_module(import_generated_module): + return import_generated_module("secrets") + + +def test_secret_round_trips_bytes_via_firestore_emulator( + secrets_module, + fixtures_root: Path, + firestore_client: firestore.Client, + isolated_collection: firestore.CollectionReference, +) -> None: + """A document with multiple `bytes` fields (including a list of bytes) + survives a Pydantic-validate -> emulator-write -> emulator-read -> + Pydantic-validate cycle byte-for-byte.""" + + Secret = secrets_module.Secret + + sample = _load_sample(fixtures_root, "api-key") + payload = base64.b64decode(sample["payload_base64"]) + checksum = base64.b64decode(sample["checksum_base64"]) + shards = [base64.b64decode(s) for s in sample["shards_base64"]] + + # Sanity-check the fixture itself: a buggy generator that aliased + # every bytes field to the same value would still pass otherwise. + assert len(payload) > 0 + assert len(checksum) == 32 + assert payload != checksum + assert len(shards) == 3 + + secret_in = Secret.model_validate( + { + "label": sample["label"], + "payload": payload, + "checksum": checksum, + "shards": shards, + "created_at": sample["created_at"], + } + ) + + # The generator emits Pydantic types that store bytes verbatim; check + # that nothing has been auto-coerced (e.g. to a base64 string) before + # we even reach Firestore. + assert isinstance(secret_in.payload, bytes) + assert isinstance(secret_in.checksum, bytes) + assert all(isinstance(s, bytes) for s in secret_in.shards) + assert secret_in.payload == payload + assert secret_in.checksum == checksum + + doc_ref = isolated_collection.document(uuid.uuid4().hex) + doc_ref.set(secret_in.model_dump()) + + snapshot = doc_ref.get() + assert snapshot.exists, "expected the written document to be readable" + + raw = snapshot.to_dict() + # Wire-level expectations: the Firestore Python client returns bytes + # as plain `bytes` for both top-level fields and entries nested + # inside a list. + assert isinstance(raw["payload"], bytes) + assert isinstance(raw["checksum"], bytes) + assert isinstance(raw["shards"], list) + assert all(isinstance(s, bytes) for s in raw["shards"]) + + # Re-validate through the generated Pydantic model to confirm the + # generated schema round-trips without rejecting plain `bytes`. + secret_out = Secret.model_validate(raw) + assert secret_out.label == sample["label"] + assert secret_out.payload == payload + assert secret_out.checksum == checksum + assert secret_out.shards == shards + + +def test_secret_rejects_non_bytes_payload(secrets_module) -> None: + """The generated Pydantic class should reject obvious type mismatches + on the bytes-typed fields (e.g. a dict where bytes are expected). + Pydantic accepts `str` as input for `bytes` (encoding it as UTF-8), + so we use a non-string non-bytes value to make the rejection + unambiguous.""" + + Secret = secrets_module.Secret + + with pytest.raises(Exception): + Secret.model_validate( + { + "label": "x", + "payload": {"not": "bytes"}, + "checksum": b"\x00", + "shards": [], + "created_at": "2024-01-01T00:00:00.000Z", + } + ) diff --git a/tests/integration/swift/Tests/TypesyncIntegrationTests/SecretsIntegrationTests.swift b/tests/integration/swift/Tests/TypesyncIntegrationTests/SecretsIntegrationTests.swift new file mode 100644 index 00000000..663e1b74 --- /dev/null +++ b/tests/integration/swift/Tests/TypesyncIntegrationTests/SecretsIntegrationTests.swift @@ -0,0 +1,116 @@ +import FirebaseFirestore +import Foundation +import Testing + +@testable import TypesyncIntegration + +// Round-trips a `bytes`-typed document through the Firestore emulator using +// the **firebase-ios-sdk** Codable bridge. The Swift generator emits +// `Foundation.Data` for each `bytes` field, which is what the Firebase iOS +// SDK uses to represent Firestore's `bytes` value type when encoding / +// decoding via `Firestore.Encoder` / `Firestore.Decoder`. +// +// We assert both the wire-level and Codable-level shapes: +// +// 1. `setData(from:)` accepts a `Secret` whose bytes-typed fields are +// `Data` values without coercion. +// 2. The raw snapshot (`snapshot.data()`) exposes those fields as +// `Data` (the Objective-C bridge from `NSData`), including bytes +// nested inside a list. +// 3. `snapshot.data(as: Secret.self)` rebuilds an equivalent `Secret` +// whose `Data` payloads are byte-for-byte identical to the input. + +@Suite("Secrets bytes round-trip via emulator") +struct SecretsIntegrationTests { + @Test("a Secret with multiple Data fields (including [Data]) round-trips through the emulator with byte-for-byte preservation") + func secretRoundTripsThroughEmulator() async throws { + let firestore = EmulatorClient.firestore() + let collection = firestore.collection(uniqueCollectionName()) + + let sample = try Fixtures.loadSampleAsDict(scenario: "secrets", name: "api-key") + let payload = try decodeBase64(try unwrap(sample["payload_base64"] as? String, "secrets/api-key.payload_base64")) + let checksum = try decodeBase64(try unwrap(sample["checksum_base64"] as? String, "secrets/api-key.checksum_base64")) + let shardsBase64 = try unwrap(sample["shards_base64"] as? [String], "secrets/api-key.shards_base64") + let shards = try shardsBase64.map { try decodeBase64($0) } + + // Sanity-check the fixture: a buggy generator that aliased all + // bytes fields to the same `Data` value would still pass + // otherwise. + #expect(!payload.isEmpty) + #expect(checksum.count == 32) + #expect(payload != checksum) + #expect(shards.count == 3) + + let secretIn = Secret( + label: try unwrap(sample["label"] as? String, "secrets/api-key.label"), + payload: payload, + checksum: checksum, + shards: shards, + createdAt: try parseISO8601(try unwrap(sample["created_at"] as? String, "secrets/api-key.created_at")) + ) + + let docRef = collection.document(UUID().uuidString) + try docRef.setData(from: secretIn) + + let snapshot = try await docRef.getDocument() + try #require(snapshot.exists) + let raw = try #require(snapshot.data(), "expected non-empty document data") + + // Wire-level expectations: the iOS SDK exposes bytes through + // Foundation.Data on the snapshot (which bridges NSData), both + // for top-level fields and for entries nested inside a list. + #expect(raw["payload"] is Data) + #expect(raw["checksum"] is Data) + let rawShards = try #require(raw["shards"] as? [Data], "shards should decode as [Data]") + #expect(rawShards.count == shards.count) + + let secretOut = try snapshot.data(as: Secret.self) + + #expect(secretOut.label == secretIn.label) + #expect(secretOut.payload == payload) + #expect(secretOut.checksum == checksum) + #expect(secretOut.shards.count == shards.count) + for (i, shard) in secretOut.shards.enumerated() { + #expect(shard == shards[i]) + } + #expect( + abs(secretOut.createdAt.timeIntervalSince1970 - secretIn.createdAt.timeIntervalSince1970) < 0.001, + "timestamp should round-trip through Firestore within ms precision" + ) + } +} + +private func uniqueCollectionName() -> String { + "test_" + UUID().uuidString.replacingOccurrences(of: "-", with: "") +} + +private struct FixtureError: Error, CustomStringConvertible { + let description: String +} + +private func unwrap(_ value: T?, _ name: String) throws -> T { + guard let value else { + throw FixtureError(description: "Missing or wrongly-typed fixture field '\(name)'.") + } + return value +} + +private func decodeBase64(_ raw: String) throws -> Data { + guard let data = Data(base64Encoded: raw) else { + throw FixtureError(description: "Invalid base64 string: \(raw)") + } + return data +} + +private func parseISO8601(_ raw: String) throws -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: raw) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: raw) { + return date + } + throw FixtureError(description: "Invalid ISO-8601 date: \(raw)") +} diff --git a/tests/integration/typescript/generated/web/secrets.ts b/tests/integration/typescript/generated/web/secrets.ts new file mode 100644 index 00000000..cd06c1d5 --- /dev/null +++ b/tests/integration/typescript/generated/web/secrets.ts @@ -0,0 +1,14 @@ +import type * as firestore from 'firebase/firestore'; + +/** A document storing opaque binary material alongside metadata. */ +export interface Secret { + /** Human-readable label for the secret. */ + label: string; + /** Opaque binary blob (encrypted material, key bytes, etc.). */ + payload: firestore.Bytes; + /** A second bytes field, e.g. a SHA-256 digest of the payload. */ + checksum: firestore.Bytes; + /** Additional bytes blobs stored as a list to exercise bytes-in-list. */ + shards: firestore.Bytes[]; + created_at: firestore.Timestamp; +} diff --git a/tests/integration/typescript/tests/secrets.admin.test.ts b/tests/integration/typescript/tests/secrets.admin.test.ts new file mode 100644 index 00000000..9eb2c3c7 --- /dev/null +++ b/tests/integration/typescript/tests/secrets.admin.test.ts @@ -0,0 +1,127 @@ +import { type App, initializeApp } from 'firebase-admin/app'; +import { type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { Secret } from '../generated/secrets.js'; + +// Round-trips a `bytes`-typed document through the Firestore emulator using +// the **firebase-admin@13** SDK. The admin SDK represents bytes as Node.js +// `Buffer`s, which is the type our generator emits for the +// `firebase-admin@*` targets. This test asserts that: +// +// 1. A typed `Secret` constructed with `Buffer` values can be written to +// the emulator without coercion. +// 2. Reading the same document back yields `Buffer`-shaped bytes. +// 3. The byte contents are preserved exactly (no UTF-8 round-tripping, +// no base64 mangling, no truncation), including a `bytes` field +// nested inside a list (`shards`). + +const FIXTURES_ROOT = resolve(__dirname, '../../_fixtures'); + +interface SecretSample { + label: string; + payload_base64: string; + checksum_base64: string; + shards_base64: string[]; + created_at: string; +} + +function loadSample(scenario: string, name: string): SecretSample { + const samplePath = resolve(FIXTURES_ROOT, 'samples', scenario, `${name}.json`); + return JSON.parse(readFileSync(samplePath, 'utf8')) as SecretSample; +} + +function ensureEmulatorEnv(): void { + if (!process.env.FIRESTORE_EMULATOR_HOST) { + throw new Error('FIRESTORE_EMULATOR_HOST is not set. Run via `yarn test:integration:typescript`.'); + } + process.env.GOOGLE_CLOUD_PROJECT ??= 'demo-integration'; + process.env.GCLOUD_PROJECT ??= process.env.GOOGLE_CLOUD_PROJECT; +} + +/** + * Byte-level equality between any two byte views (Node `Buffer` or + * plain `Uint8Array`). Avoids `Buffer.from(view)` so we don't have to + * reconcile `Buffer` vs `Uint8Array` under strict TS. + */ +function bytesEqual(a: Buffer | Uint8Array, b: Buffer | Uint8Array): boolean { + if (a.byteLength !== b.byteLength) return false; + for (let i = 0; i < a.byteLength; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +describe('Secrets bytes round-trip (firebase-admin@13)', () => { + let app: App; + let firestore: Firestore; + + beforeAll(() => { + ensureEmulatorEnv(); + app = initializeApp({ projectId: process.env.GOOGLE_CLOUD_PROJECT }, 'secrets-admin-test-app'); + firestore = getFirestore(app); + }); + + afterAll(async () => { + await firestore.terminate(); + }); + + it('round-trips a typed Secret document with Buffer-valued bytes fields through the emulator', async () => { + const sample = loadSample('secrets', 'api-key'); + + const payloadBuf = Buffer.from(sample.payload_base64, 'base64'); + const checksumBuf = Buffer.from(sample.checksum_base64, 'base64'); + const shardsBufs = sample.shards_base64.map(s => Buffer.from(s, 'base64')); + + // Sanity-check the test fixture itself: the base64 strings must decode + // to non-empty bytes that are *not* coincidentally equal to each other, + // otherwise an SDK that quietly aliased all bytes fields would still + // pass. + expect(payloadBuf.length).toBeGreaterThan(0); + expect(checksumBuf.length).toBe(32); + expect(shardsBufs.length).toBe(3); + expect(bytesEqual(payloadBuf, checksumBuf)).toBe(false); + + const secretIn: Secret = { + label: sample.label, + payload: payloadBuf, + checksum: checksumBuf, + shards: shardsBufs, + created_at: Timestamp.fromDate(new Date(sample.created_at)), + }; + + const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`); + const docRef = collection.doc(crypto.randomUUID()); + await docRef.set(secretIn); + + const snapshot = await docRef.get(); + expect(snapshot.exists).toBe(true); + + const raw = snapshot.data() as Record; + // Wire-level expectations: the admin SDK returns bytes as Buffer + // (which is a subclass of Uint8Array). We assert against the wider + // Uint8Array contract so the test is robust against a future SDK that + // might switch to plain typed arrays. + expect(raw.payload).toBeInstanceOf(Uint8Array); + expect(raw.checksum).toBeInstanceOf(Uint8Array); + expect(Array.isArray(raw.shards)).toBe(true); + for (const s of raw.shards as unknown[]) { + expect(s).toBeInstanceOf(Uint8Array); + } + + const secretOut = snapshot.data() as Secret; + + expect(secretOut.label).toBe(secretIn.label); + expect(bytesEqual(secretOut.payload, payloadBuf)).toBe(true); + expect(bytesEqual(secretOut.checksum, checksumBuf)).toBe(true); + expect(secretOut.shards.length).toBe(shardsBufs.length); + secretOut.shards.forEach((shard, i) => { + const expected = shardsBufs[i]; + expect(expected).toBeDefined(); + expect(bytesEqual(shard, expected!)).toBe(true); + }); + expect(secretOut.created_at.toMillis()).toBe(secretIn.created_at.toMillis()); + }); +}); diff --git a/tests/integration/typescript/tests/secrets.web.test.ts b/tests/integration/typescript/tests/secrets.web.test.ts new file mode 100644 index 00000000..4d05c5c5 --- /dev/null +++ b/tests/integration/typescript/tests/secrets.web.test.ts @@ -0,0 +1,153 @@ +import { type FirebaseApp, initializeApp as initializeWebApp } from 'firebase/app'; +import { + Bytes, + type Firestore, + Timestamp, + connectFirestoreEmulator, + doc, + getDoc, + getFirestore, + setDoc, + terminate, +} from 'firebase/firestore'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { Secret } from '../generated/web/secrets.js'; + +// Round-trips a `bytes`-typed document through the Firestore emulator using +// the **firebase@10 web SDK**. Where the admin SDK uses `Buffer`, the web +// SDK exposes bytes through the immutable `Bytes` value type. Our generator +// emits `firestore.Bytes` for the `firebase@*` targets, and this test +// proves that the generated type is the right one in practice: +// +// 1. A typed `Secret` constructed with `Bytes.fromUint8Array(...)` +// values is accepted by `setDoc` without coercion. +// 2. Reading back with `getDoc` returns `Bytes` instances for every +// bytes-typed field, including bytes nested inside a list. +// 3. The byte contents are preserved exactly when round-tripped through +// base64. +// +// Confirming both the admin (`Buffer`) and web (`Bytes`) shapes via the +// emulator is the integration-level proof that the cross-target plumbing +// for the `bytes` primitive really works. + +const FIXTURES_ROOT = resolve(__dirname, '../../_fixtures'); + +interface SecretSample { + label: string; + payload_base64: string; + checksum_base64: string; + shards_base64: string[]; + created_at: string; +} + +function loadSample(scenario: string, name: string): SecretSample { + const samplePath = resolve(FIXTURES_ROOT, 'samples', scenario, `${name}.json`); + return JSON.parse(readFileSync(samplePath, 'utf8')) as SecretSample; +} + +function ensureEmulatorEnv(): { host: string; port: number } { + const raw = process.env.FIRESTORE_EMULATOR_HOST; + if (!raw) { + throw new Error('FIRESTORE_EMULATOR_HOST is not set. Run via `yarn test:integration:typescript`.'); + } + // FIRESTORE_EMULATOR_HOST is conventionally `host:port`, e.g. + // `localhost:8080`. The web SDK takes them as separate args. + const lastColon = raw.lastIndexOf(':'); + if (lastColon < 0) { + throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} is not in host:port form`); + } + const host = raw.slice(0, lastColon); + const port = Number(raw.slice(lastColon + 1)); + if (!Number.isFinite(port) || port <= 0) { + throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} has an invalid port`); + } + process.env.GOOGLE_CLOUD_PROJECT ??= 'demo-integration'; + return { host, port }; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +describe('Secrets bytes round-trip (firebase@10 web SDK)', () => { + let app: FirebaseApp; + let firestore: Firestore; + + beforeAll(() => { + const { host, port } = ensureEmulatorEnv(); + app = initializeWebApp( + { + // Placeholder credentials. The emulator does not validate them + // and we never reach a real Firebase project. + apiKey: 'fake-api-key', + projectId: process.env.GOOGLE_CLOUD_PROJECT, + }, + 'secrets-web-test-app' + ); + firestore = getFirestore(app); + connectFirestoreEmulator(firestore, host, port); + }); + + afterAll(async () => { + await terminate(firestore); + }); + + it('round-trips a typed Secret document with firestore.Bytes values through the emulator', async () => { + const sample = loadSample('secrets', 'api-key'); + + const payloadBytes = Bytes.fromBase64String(sample.payload_base64); + const checksumBytes = Bytes.fromBase64String(sample.checksum_base64); + const shardsBytes = sample.shards_base64.map(s => Bytes.fromBase64String(s)); + + // Sanity-check the fixture so a buggy `Bytes` implementation that + // collapsed every input to the same value would still be caught. + expect(payloadBytes.toBase64()).toBe(sample.payload_base64); + expect(checksumBytes.toBase64()).toBe(sample.checksum_base64); + expect(payloadBytes.isEqual(checksumBytes)).toBe(false); + + const secretIn: Secret = { + label: sample.label, + payload: payloadBytes, + checksum: checksumBytes, + shards: shardsBytes, + created_at: Timestamp.fromDate(new Date(sample.created_at)), + }; + + const collection = `test_${crypto.randomUUID().replaceAll('-', '')}`; + const docRef = doc(firestore, collection, crypto.randomUUID()); + await setDoc(docRef, secretIn); + + const snapshot = await getDoc(docRef); + expect(snapshot.exists()).toBe(true); + + const raw = snapshot.data() as Record; + // Wire-level expectations: the web SDK exposes bytes as the `Bytes` + // value type, including for entries nested inside a list. + expect(raw.payload).toBeInstanceOf(Bytes); + expect(raw.checksum).toBeInstanceOf(Bytes); + expect(Array.isArray(raw.shards)).toBe(true); + for (const s of raw.shards as unknown[]) { + expect(s).toBeInstanceOf(Bytes); + } + + const secretOut = snapshot.data() as Secret; + + expect(secretOut.label).toBe(secretIn.label); + expect(secretOut.payload.isEqual(payloadBytes)).toBe(true); + expect(secretOut.checksum.isEqual(checksumBytes)).toBe(true); + expect(secretOut.shards.length).toBe(shardsBytes.length); + secretOut.shards.forEach((shard, i) => { + const expected = shardsBytes[i]; + expect(expected).toBeDefined(); + expect(shard.isEqual(expected!)).toBe(true); + // And the underlying byte contents should match the source fixture. + expect(bytesToHex(shard.toUint8Array())).toBe(bytesToHex(expected!.toUint8Array())); + }); + expect(secretOut.created_at.toMillis()).toBe(secretIn.created_at.toMillis()); + }); +}); diff --git a/tests/security/definition.yml b/tests/security/definition.yml index f0257087..df34b207 100644 --- a/tests/security/definition.yml +++ b/tests/security/definition.yml @@ -94,6 +94,19 @@ WorkspaceInfo: description: type: string +Secret: + model: document + path: secrets/{secretId} + type: + type: object + fields: + label: + type: string + payload: + type: bytes + checksum: + type: bytes + Workspace: model: document path: workspaces/{workspaceId} diff --git a/tests/security/firestore.rules b/tests/security/firestore.rules index 758505d1..01b9e5f5 100644 --- a/tests/security/firestore.rules +++ b/tests/security/firestore.rules @@ -44,6 +44,16 @@ service cloud.firestore { ); } + function isValidSecret(data) { + return ( + (data is map) && + (data.keys().hasOnly(['label', 'payload', 'checksum'])) && + (data.label is string) && + (data.payload is bytes) && + (data.checksum is bytes) + ); + } + function isValidUser(data) { return ( (data is map) && @@ -93,5 +103,12 @@ service cloud.firestore { match /projects/{projectId} { allow read, create, update, delete; } + + match /secrets/{secretId} { + allow read; + allow create: if isValidSecret(request.resource.data); + allow update: if isValidSecret(request.resource.data); + allow delete: if false; + } } } \ No newline at end of file diff --git a/tests/security/rules.test.ts b/tests/security/rules.test.ts index 22e2df88..63619b02 100644 --- a/tests/security/rules.test.ts +++ b/tests/security/rules.test.ts @@ -4,7 +4,7 @@ import { assertSucceeds, initializeTestEnvironment, } from '@firebase/rules-unit-testing'; -import { doc, setDoc, updateDoc } from 'firebase/firestore'; +import { Bytes, doc, getDoc, setDoc, updateDoc } from 'firebase/firestore'; import { readFileSync } from 'fs'; import { resolve } from 'path'; @@ -87,6 +87,80 @@ describe('Security Rules', () => { ).resolves.toBeDefined(); }); + // The /secrets/* collection exercises the `bytes` primitive end-to-end: + // the rules generator should emit `data. is bytes`, and the + // emulator should evaluate that predicate against `firestore.Bytes` + // values written from the web SDK. These tests prove both halves at + // runtime and not just via snapshot-of-generator-output. + describe('bytes', () => { + const secretId = 'secret123'; + const secretDocPath = `/secrets/${secretId}`; + + it('allows create when bytes-typed fields are sent as firestore.Bytes', async () => { + const ctx = testEnv.unauthenticatedContext(); + const ref = doc(ctx.firestore(), secretDocPath); + await expect( + assertSucceeds( + setDoc(ref, { + label: 'primary', + payload: Bytes.fromUint8Array(new Uint8Array([0x01, 0x02, 0x03, 0x04])), + checksum: Bytes.fromBase64String('MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM='), + }) + ) + ).resolves.toBeUndefined(); + + // And the document is readable back as bytes (sanity-check that + // the emulator did not reject silently or coerce to a string). + const snapshot = await getDoc(ref); + expect(snapshot.exists()).toBe(true); + const raw = snapshot.data() as Record; + expect(raw.payload).toBeInstanceOf(Bytes); + expect(raw.checksum).toBeInstanceOf(Bytes); + }); + + it('blocks create when a bytes-typed field is sent as a string', async () => { + const ctx = testEnv.unauthenticatedContext(); + const ref = doc(ctx.firestore(), secretDocPath); + await expect( + assertFails( + setDoc(ref, { + label: 'primary', + payload: 'not-bytes-just-a-string', + checksum: Bytes.fromUint8Array(new Uint8Array(32)), + }) + ) + ).resolves.toBeDefined(); + }); + + it('blocks create when a bytes-typed field is sent as a number', async () => { + const ctx = testEnv.unauthenticatedContext(); + const ref = doc(ctx.firestore(), secretDocPath); + await expect( + assertFails( + setDoc(ref, { + label: 'primary', + payload: Bytes.fromUint8Array(new Uint8Array([0xff])), + checksum: 12345, + }) + ) + ).resolves.toBeDefined(); + }); + + it('blocks create when a bytes-typed field is missing entirely', async () => { + const ctx = testEnv.unauthenticatedContext(); + const ref = doc(ctx.firestore(), secretDocPath); + await expect( + assertFails( + setDoc(ref, { + label: 'primary', + payload: Bytes.fromUint8Array(new Uint8Array([0xff])), + // checksum: missing + }) + ) + ).resolves.toBeDefined(); + }); + }); + afterEach(async () => { await testEnv.clearFirestore(); });