Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Support for imports #86

Merged
merged 1 commit into from
Mar 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
test/helpers/compiled.js
test/helpers/imports.js
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,28 @@ var js = protobuf.toJS(fs.readFileSync('test.proto'))
fs.writeFileSync('messages.js', js)
```

## Imports

The cli tool supports protocol buffer [imports][] by default.

**Currently all imports are treated as public and the public/weak keywords
not supported.**

To use it programmatically you need to pass-in a `filename` & a `resolveImport`
hooks:

```js
var protobuf = require('protocol-buffers')
var messages = protobuf(null, {
filename: 'initial.proto',
resolveImport (filename) {
// can return a Buffer, String or Schema
}
})
```

[imports]: https://developers.google.com/protocol-buffers/docs/proto3#importing_definitions

## Performance

This module is fast.
Expand Down
48 changes: 35 additions & 13 deletions bin.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,45 @@
#!/usr/bin/env node
const protobuf = require('./')
const fs = require('fs')
const path = require('path')

let filename = null
let output = null
let watch = false
let encodings = null
const importPaths = []

// handrolled parser to not introduce minimist as this is used a bunch of prod places
// TODO: if this becomes more complicated / has bugs, move to minimist
for (let i = 2; i < process.argv.length; i++) {
const v = process.argv[i]
const n = v.split('=')[0]
if (v[0] !== '-') {
filename = v
} else if (n === '--output' || n === '-o' || n === '-wo') {
if (n === '-wo') watch = true
output = v === n ? process.argv[++i] : v.split('=').slice(1).join('=')
} else if (n === '--watch' || n === '-w') {
const parts = process.argv[i].split('=')
const key = parts[0]
const value = parts.slice(1).join('=')
if (key[0] !== '-') {
filename = path.resolve(key)
} else if (key === '--output' || key === '-o' || key === '-wo') {
if (key === '-wo') watch = true
output = value || process.argv[++i]
} else if (key === '--watch' || key === '-w') {
watch = true
} else if (n === '--encodings' || n === '-e') {
encodings = v === n ? process.argv[++i] : v.split('=').slice(1).join('=')
} else if (key === '--encodings' || key === '-e') {
encodings = value || process.argv[++i]
} else if (key === '--proto_path' || key === '-I') {
importPaths.push(path.resolve(value || process.argv[++i]))
}
}
importPaths.push(process.cwd())

if (!filename) {
console.error('Usage: protocol-buffers [schema-file.proto] [options]')
console.error()
console.error(' --output, -o [output-file.js]')
console.error(' --watch, -w (recompile on schema change)')
console.error(' --output, -o [output-file.js]')
console.error(' --watch, -w (recompile on schema change)')
console.error(' --proto_path, -I [path-root] # base to lookup imports, multiple supported')
console.error()
process.exit(1)
}
filename = path.relative(process.cwd(), filename)

if (watch && !output) {
console.error('--watch requires --output')
Expand All @@ -49,6 +57,20 @@ function write () {
fs.writeFileSync(output, compile())
}

function resolveImport (filename) {
for (let i = 0; i < importPaths.length; i++) {
const importPath = importPaths[i]
try {
return fs.readFileSync(path.join(importPath, filename))
} catch (err) {}
}
throw new Error('File "' + filename + '" not found in import path:\n - ' + importPaths.join('\n - '))
}

function compile () {
return protobuf.toJS(fs.readFileSync(filename), { encodings })
return protobuf.toJS(null, {
encodings: encodings,
filename: filename,
resolveImport: resolveImport
})
}
36 changes: 32 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,39 @@ const flatten = function (values) {
return result
}

function resolveImport (filename, opts, context) {
if (!opts.resolveImport) throw new Error('opts.resolveImport is required if opts.filename is given.')
return _resolveImport(filename, opts, context)
}

function _resolveImport (filename, opts, context) {
if (context.stack.has(filename)) {
throw new Error('File recursively imports itself: ' + Array.from(context.stack).concat(filename).join(' -> '))
}
context.stack.add(filename)
const importData = opts.resolveImport(filename)
const sch = (typeof importData === 'object' && !Buffer.isBuffer(importData)) ? importData : schema.parse(importData)
sch.imports.forEach(function (importDef) {
const imported = _resolveImport(importDef, opts, context)
sch.enums = sch.enums.concat(imported.enums)
sch.messages = sch.messages.concat(imported.messages)
})
context.stack.delete(filename)
return sch
}

module.exports = function (proto, opts) {
if (!opts) opts = {}
if (!proto) throw new Error('Pass in a .proto string or a protobuf-schema parsed object')

const sch = (typeof proto === 'object' && !Buffer.isBuffer(proto)) ? proto : schema.parse(proto)
let sch
if (opts.filename) {
sch = resolveImport(opts.filename, opts, {
cache: {},
stack: new Set()
})
} else {
if (!proto) throw new Error('Pass in a .proto string or a protobuf-schema parsed object')
sch = (typeof proto === 'object' && !Buffer.isBuffer(proto)) ? proto : schema.parse(proto)
}

// to not make toString,toJSON enumarable we make a fire-and-forget prototype
const Messages = function () {
Expand All @@ -38,5 +66,5 @@ module.exports = function (proto, opts) {
}

module.exports.toJS = function (proto, opts) {
return compileToJS(module.exports(proto, { inlineEnc: true }), opts)
return compileToJS(module.exports(proto, Object.assign({ inlineEnc: true }, opts)), opts)
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"scripts": {
"test": "standard && npm run test-generated && npm run test-compiled",
"test-generated": "tape test/*.js",
"test-compiled": "./bin.js test/test.proto -o test/helpers/compiled.js && COMPILED=true tape test/*.js",
"test-compiled": "./bin.js test/test.proto -o test/helpers/compiled.js && ./bin.js test/imports/valid.proto -o test/helpers/imports.js && COMPILED=true tape test/*.js",
"bench": "node bench"
},
"bugs": {
Expand Down
132 changes: 132 additions & 0 deletions test/imports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
const tape = require('tape')
const fs = require('fs')
const path = require('path')
const compile = require('..')
const schema = require('protocol-buffers-schema')

const projectBase = path.resolve(__dirname, '..')
function resolveImport (filename) {
const filepath = path.resolve(projectBase, filename)
return fs.readFileSync(filepath)
}

function load (filename) {
return {
filename: filename,
raw: fs.readFileSync(path.join(projectBase, filename))
}
}

const valid = load('test/imports/valid.proto')

function testCompiled (t, compiled) {
t.deepEqual(compiled.DeepestEnum, {
A: 1,
B: 2,
C: 3
})

const encoded = {
deepestMessage: compiled.DeepestMessage.encode({
field: 3
}),
deeper: compiled.Deeper.encode({
foo: {
field: 1
},
bar: 2
}),
deeper2: compiled.Deeper2.encode({
foo: {
field: 3
}
}),
valid: compiled.Valid.encode({
ext: {
foo: {
field: 3
},
bar: 1
}
})
}

t.deepEqual(encoded.deeper, Buffer.from('0a0208011002', 'hex'))

const decoded = {
deepestMessage: compiled.DeepestMessage.decode(encoded.deepestMessage),
deeper: compiled.Deeper.decode(encoded.deeper)
}
t.deepEqual(decoded, {
deepestMessage: {
field: 3
},
deeper: {
foo: {
field: 1
},
bar: 2
}
})
t.end()
}

if (process.env.COMPILED) {
tape('validated compiled', function (t) {
testCompiled(t, require('./helpers/imports.js'))
})
}

tape('valid imports', function (t) {
testCompiled(t, compile(null, {
filename: valid.filename,
resolveImport: resolveImport
}))
})

const deeper = load('test/imports/folder-a/folder-b/deeper.proto')
const deeper2 = load('test/imports/folder-a/folder-b/deeper2.proto')
const deepest = load('test/imports/folder-a/deepest.proto')

tape('valid import with pre-parsed schemas', function (t) {
const cache = {}
cache[valid.filename] = schema.parse(valid.raw)
cache[deeper.filename] = schema.parse(deeper.raw)
cache[deeper2.filename] = schema.parse(deeper2.raw)
cache[deepest.filename] = schema.parse(deepest.raw)
t.deepEquals(Object.keys(compile(null, {
filename: valid.filename,
resolveImport: function (filename) {
return cache[filename]
}
})), ['DeepestEnum', 'Valid', 'Deeper', 'DeepestMessage', 'Deeper2'])
t.end()
})

tape('valid import without resolving', function (t) {
t.throws(function () {
compile(valid.raw, {})
}, /Could not resolve Deeper/)
t.end()
})

tape('import with .filename, without resolveImports', function (t) {
t.throws(function () {
compile(null, {
filename: 'test'
})
}, /opts.resolveImport is required if opts.filename is given./)
t.end()
})

const invalid = load('test/imports/invalid.proto')

tape('circular import', function (t) {
t.throws(function () {
compile(invalid.raw, {
filename: invalid.filename,
resolveImport: resolveImport
})
}, /File recursively imports itself: test\/imports\/invalid.proto -> test\/imports\/folder-a\/circular.proto -> test\/imports\/invalid.proto/)
t.end()
})
5 changes: 5 additions & 0 deletions test/imports/folder-a/circular.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import "test/imports/invalid.proto";

message Circular {
required int32 num = 1;
}
9 changes: 9 additions & 0 deletions test/imports/folder-a/deepest.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
enum DeepestEnum {
A=1;
B=2;
C=3;
}

message DeepestMessage {
optional DeepestEnum field = 1 [default = B];
}
6 changes: 6 additions & 0 deletions test/imports/folder-a/folder-b/deeper.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import "test/imports/folder-a/deepest.proto";

message Deeper {
required DeepestMessage foo = 1;
required DeepestEnum bar = 2;
}
7 changes: 7 additions & 0 deletions test/imports/folder-a/folder-b/deeper2.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// This is added to check cache when two different files
// reference the same deep file.
import "test/imports/folder-a/deepest.proto";

message Deeper2 {
required DeepestMessage foo = 1;
}
5 changes: 5 additions & 0 deletions test/imports/invalid.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import "test/imports/folder-a/circular.proto";

message Invalid {
required int32 num = 1;
}
6 changes: 6 additions & 0 deletions test/imports/valid.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import "test/imports/folder-a/folder-b/deeper.proto";
import "test/imports/folder-a/folder-b/deeper2.proto";

message Valid {
required Deeper ext = 1;
}