Skip to content

Latest commit

 

History

History
542 lines (441 loc) · 20.5 KB

yang.litcoffee

File metadata and controls

542 lines (441 loc) · 20.5 KB

Yang - evaluable schema expression

This module provides support for basic set of YANG schema modeling language by using the built-in extension syntax to define additional schema language constructs. The actual YANG language RFC 6020 specifications are loaded inside the main module.

This module is the primary interface for consumers of this library.

Dependencies

debug  = require('debug')('yang:schema') if process.env.DEBUG?
fs     = require 'fs'
path   = require 'path'
parser = require 'yang-parser'
indent = require 'indent-string'

Expression = require './expression'
XPath = require './xpath'

Class Yang

class Yang extends Expression
  @scope:
    extension: '0..n'
    typedef:   '0..n'
    module:    '0..n'
    submodule: '0..n'

Class-level methods

  @clear: ->
    @module.splice(0,@module.length) if @module?
    @submodule.splice(0,@submodule.length) if @submodule?

parse (schema)

This class-level routine performs recursive parsing of passed in statement and sub-statements. It provides syntactic, semantic and contextual validations on the provided schema and returns the final JS object tree structure as hierarchical Yang expression instances.

If any validation errors are encountered, it will throw the appropriate error along with the context information regarding the error.

  @parse: (schema, opts={}) ->
    opts.compile ?= true
    try
      schema = parser.parse schema if typeof schema is 'string'
    catch e
      e.offset = 50 unless e.offset > 50
      offender = schema.slice e.offset-50, e.offset+50
      offender = offender.replace /\s\s+/g, ' '
      throw @error "invalid YANG syntax detected around: '#{offender}'", offender

    unless schema instanceof Object
      throw @error "must pass in valid YANG schema", schema

    kind = switch
      when !!schema.prf then "#{schema.prf}:#{schema.kw}"
      else schema.kw
    tag = schema.arg unless schema.arg is false
	
    schema = (new this kind, tag).extends schema.substmts.map (x) => @parse x, compile: false
    # perform final scoped constraint validation
    for kind, constraint of schema.scope when constraint in [ '1', '1..n' ]
      unless schema.hasOwnProperty kind
        throw schema.error "constraint violation for required '#{kind}' = #{constraint}"
    schema.compile() if opts.compile
    return schema

For comprehensive overview on currently supported YANG statements, please refer to Compliance Report for the latest RFC 6020 YANG specification compliance.

compose (data [, opts={}])

This call accepts any arbitrary JS object and it will attempt to convert it into a structural Yang expression instance. It will analyze the passed in JS data and perform best match mapping to an appropriate YANG schema representation to describe the input data. This method will not be able to determine conditionals or any meta-data to further constrain the data, but it should provide a good starting point with the resulting Yang expression instance.

  @compose: (data, opts={}) ->
    unless data?
      throw @error "must supply input 'data' to compose"

    # explict compose
    if opts.kind?
      ext = Yang::lookup.call this, 'extension', opts.kind
      unless ext instanceof Expression
        throw @error "unable to find requested '#{opts.kind}' extension"
      return ext.compose? data, opts

    # implicit compose (dynamic discovery)
    for ext in @extension when ext.compose instanceof Function
      debug? "checking data if #{ext.tag}"
      res = ext.compose data, opts
      return res if res instanceof Yang

This facility is a powerful construct to dynamically generate Yang schema from ordinary JS objects. For additional usage examples, please refer to Dynamic Composition section in the Getting Started Guide.

resolve (from..., name)

This call is internally used by import to perform a search within the local filesystem to locate a given YANG schema module by name. It will first check the calling code's local package.json to look for a yang: { resolve: {} } configuration section to identify where the target module can be found. If there is an entry defined, it will then follow that reference - which may be a JS file, YANG schema text file, or another NPM module. If it is not found within the yang: { resolve: {} } configuration block or it fails to load the referenced dependency, it will then fallback to attempt to locate a YANG schema text file in the same folder that the resolve request was made: #{name}.yang.

  @resolve: (from..., name) ->
    return null unless typeof name is 'string'
    dir = from = switch
      when from.length then from[0]
      else path.resolve()
    while not found? and dir not in [ '/', '.' ]
      target = "#{dir}/package.json"
      debug? "[resolve] #{name} in #{target}"
      try
        pkginfo = require(target)
        found = pkginfo.yang?.resolve?[name] ? pkginfo.models[name]
      if found?
        dir = path.dirname require.resolve(target)
        debug? "[resolve] #{name} check #{found} in #{dir}"
        unless !!path.extname found
          from = switch
            when found of pkginfo.dependencies
              path.resolve dir, 'node_modules', found
            else path.resolve dir, found
          if fs.existsSync from
            return @resolve from, name
          else
            found = @resolve found, name
      if not found? and pkginfo?.name is name
        found = path.dirname require.resolve(target)
      dir = path.dirname dir unless found?
    file = switch
      when not found? then path.resolve from, "#{name}.yang"
      else path.resolve dir, found
    debug? "[resolve] checking if #{file} exists"
    return if fs.existsSync file then file else null

import (name [, opts={}])

This call provides a convenience mechanism for dealing with YANG schema module dependencies. It performs parsing of the YANG schema content from the specified name and saves the generated Yang expression inside the internal registry. The name can be a YANG module name or a filename to the actual schema content (JS or YANG).

Once a given YANG module has been saved inside the registry, subsequent parse of YANG schema that import the saved module will successfully resolve.

Typical usage scenario for this pattern is to internally define common modules such as ietf-yang-types which can then be imported by other schemas.

It will also return the new Yang expression instance (to do with as you please).

  @import: (name, opts={}) ->
    return unless name?
    opts.basedir ?= ''
    extname  = path.extname name
    filename = path.resolve opts.basedir, name
    basedir  = path.dirname filename

    unless !!extname
      return (Yang::match.call this, 'module', name) ? @import (@resolve name), opts

    unless extname is '.yang'
      res = require filename
      unless res instanceof Yang
        throw @error "unable to import '#{name}' from '#{filename}' (not Yang expression)", res
      return res 

    try return @use (@parse (fs.readFileSync filename, 'utf-8'), opts)
    catch e
      debug? e
      unless opts.compile and e.name is 'ExpressionError' and e.context.kind in [ 'include', 'import' ]
        console.error "unable to parse '#{name}' YANG module from '#{filename}'"
        throw e
      if e.context.kind is 'include'
        opts = Object.assign {}, opts
        opts.compile = false 

      # try to find the dependency module for import
      dependency = @import (@resolve basedir, e.context.tag), opts
      unless dependency?
        e.message = "unable to auto-resolve '#{e.context.tag}' dependency module"
        throw e
      unless dependency.tag is e.context.tag
        e.message = "found mismatching module '#{dependency.tag}' while resolving '#{e.context.tag}'"
        throw e

      # retry the original request
      debug? "retrying import(#{name})"
      return @import arguments...

Please note that this method will look for the name in current working directory of the script execution if the name is a relative path. It utilizes the resolve method and will attempt to recursively resolve any failed import dependencies.

While this is a convenient abstraction, it is recommended to directly use the Node.js built-in require mechanism (if available). Using native require instead of Yang.import will allow package bundlers such as browserify to capture the dependencies as part of the produced bundle. It also allows you to directly load YANG schema files from other NPM modules.

By default, loading the yang-js module will attempt to associate .yang extension inside require facility. If available, it will allow you to require('./some-dependency.yang') and get back a parsed Yang expression instance.

Main constructor

This method can be called directly without the use of new keyword and will internally parse the provided schema and return a bound function which will invoke eval when called.

  constructor: (kind, tag, extension) ->
    unless this instanceof Yang
      [ schema, bindings ] = arguments
      schema = Yang.parse schema unless schema instanceof Yang
      return schema.bind bindings

    extension ?= (@lookup 'extension', kind)
    self = super kind, tag, extension
    unless extension instanceof Expression
      @debug "defer processing of custom extension #{kind}"
      @once 'compile:before', (->
        @debug "processing deferred extension #{kind}"
        extension = (@lookup 'extension', kind)
        unless extension instanceof Yang
          throw @error "encountered unknown extension '#{kind}'"
        { @source, @argument } = extension
      ).bind self
    return self

  @property 'datakey',
    get: -> switch
      when @parent instanceof Yang and @parent.kind is 'module' then "#{@parent.tag}:#{@tag}"
      when @parent instanceof Yang and @parent.kind is 'submodule'
        "#{@parent['belongs-to'].tag}:#{@tag}"
      else @tag ? @kind

  @property 'datapath',
    get: -> switch
      when @parent not instanceof Yang then ''
      when @node then @parent.datapath + "/#{@datakey}"
      else @parent.datapath + "/#{@kind}(#{@datakey})"
              
  error: (msg, context) -> super "[#{@trail}] #{msg}", context
  
  emit: (event, args...) ->
    @emitter.emit arguments...
    @root.emit event, this if event is 'change' and this isnt @root

Instance-level methods

bind (obj)

Every instance of Yang expression can be bound with control logic which will be used during eval to produce schema infused adaptive data object. This routine is inherited from Class Expression.

This facility can be used to associate default behaviors for any element in the configuration tree, as well as handler logic for various YANG statements such as rpc, feature, etc.

This call will return the original Yang expression instance with the new bindings registered within the Yang expression hierarchy.

  # bind() is inherited from Expression

Please refer to Schema Binding section of the Getting Started Guide for usage examples.

eval (data, opts={})

Every instance of Yang expression can be eval with arbitrary JS data input which will apply the schema against the provided data and return a schema infused adaptive data object.

This is an extremely useful construct which brings out the true power of YANG for defining and governing arbitrary JS data structures.

Basically, the input data will be YANG schema validated and converted to a schema infused adaptive data model that dynamically defines properties according to the schema expressions.

It currently supports the opts.adaptive parameter (default false) which establishes a persistent binding relationship with the governing Yang expression instance. This allows the generated model to dynamically adapt to any changes to the governing Yang expression instance. Refer to below extends section for additional info on how the schema can be programmatically modified.

  eval: (data, opts={}) ->
    if opts.adaptive is true
      # TODO: this will break for 'module' which will return Model?
      @once 'change', arguments.callee.bind(this, data, opts)
    super

Please refer to Working with Models section of the Getting Started Guide for special usage examples for module schemas.

extends (schema...)

Every instance of Yang expression can be extends with additional YANG schema string(s) and it will automatically perform parse of the provided schema text and update itself accordingly.

This action also triggers an event emitter which will retroactively adapt any previously eval produced adaptive data model instances to react accordingly to the newly changed underlying schema expression(s).

  # extends() is inherited from Element

  merge: (elem) ->
    unless elem instanceof Yang
      throw @error "cannot merge invalid element into Yang", elem

    switch elem.kind
      when 'type'     then super elem, append: true
      when 'argument' then super elem, replace: true
      else super

Please refer to Schema Extension section of the Getting Started Guide for usage examples.

  normalizePath: (ypath) ->
    lastPrefix = null
    prefix2module = (root, prefix) ->
      return unless root.kind is 'module'
      switch
        when root.tag is prefix then prefix
        when root.prefix.tag is prefix then root.tag
        else
          for m in root.import ? [] when m.tag is prefix or m.prefix.tag is prefix
            return m.tag
          modules = root.lookup 'module'
          for m in modules when m.tag is prefix or m.prefix.tag is prefix
            return m.tag
          return prefix # return as-is...
          
    normalizeEntry = (x) =>
      return x unless x? and !!x
      match = x.match /^(?:([._-\w]+):)?([.{[<\w][.,+_\-}():>\]\w]*)(?:\[.+\])?$/
      unless match?
        throw @error "invalid path expression '#{x}' found in #{ypath}"
      [ prefix, target ] = [ match[1], match[2] ]
      return switch
        when not prefix? then target
        when prefix is lastPrefix then target
        else
          lastPrefix = prefix
          mname = prefix2module @root, prefix
          "#{mname}:#{target}"
    ypath = ypath.replace /\s/g, ''
    res = XPath.split(ypath).map(normalizeEntry).join('/')
    res = '/' + res if /^\//.test ypath
    return res

locate (ypath)

This is an internal helper facility used to locate a given schema node within the Yang schema expression tree hierarchy. It supports a limited version of XPATH-like expression to locate an explicit element.

  locate: (ypath) ->
    # TODO: figure out how to eliminate duplicate code-block section
    # shared with Element
    return unless ypath?
    
    @debug "locate enter for '#{ypath}'"
    if typeof ypath is 'string'
      if (/^\//.test ypath) and this isnt @root
        return @root.locate ypath
      [ key, rest... ] = @normalizePath(ypath).split('/').filter (e) -> !!e
    else
      [ key, rest... ] = ypath
    return this unless key? and key isnt '.'

    if key is '..'
      return @parent?.locate rest

    match = key.match /^(?:([._-\w]+):)?([.{[<\w][.,+_\-}():>\]\w]*)$/
    [ prefix, target ] = [ match[1], match[2] ]
    if prefix? and this is @root
      search = [target].concat(rest)
      if (@tag is prefix) or (@lookup 'prefix', prefix)
        @debug "locate (local) '/#{prefix}:#{search.join('/')}'"
        return super search
      for m in @import ? [] when m.tag is prefix or m.prefix.tag is prefix
        @debug "locate (external) '/#{prefix}:#{search.join('/')}'"
        return m.module.locate search
      m = @lookup 'module', prefix
      return m?.locate search

    switch
      when /^{.+}$/.test(target)
        kind = 'grouping'
        tag  = target.replace /^{(.+)}$/, '$1'
      when /^\[.+\]$/.test(target)
        kind = 'feature'
        tag  = target.replace /^\[(.+)\]$/, '$1'
      when /^[^(]+\([^)]*\)$/.test(target)
        target = target.match /^([^(]+)\((.*)\)$/
        [ kind, tag ] = [ target[1], target[2] ]
        tag = undefined unless !!tag
      when /^\<.+\>$/.test(target)
        target = target.replace /^\<(.+)\>$/, '$1'
        [ kind..., tag ]  = target.split ':'
        [ tag, selector ] = tag.split '='
        kind = kind[0] if kind?.length
      else return super [key].concat rest
        
    match = @match kind, tag
    return switch
      when rest.length is 0 then match
      else match?.locate rest

match (kind, tag)

This is an internal helper facility used by locate and lookup to test whether a given entity exists in the local schema tree.

  # Yang Expression can support 'tag' with prefix to another module
  # (or itself).
  match: (kind, tag) ->
    return super unless kind? and tag? and typeof tag is 'string'
    res = super
    return res if res?
    
    [ prefix..., arg ] = tag.split ':'
    return unless prefix.length

    debug? "[match] with #{kind} #{tag}"

    prefix = prefix[0]
    debug? "[match] check if current module's prefix"
    if @root.tag is prefix or @root.prefix?.tag is prefix
      return @root.match kind, arg

    debug? "[match] checking if submodule's parent"
    ctx = @lookup 'belongs-to'
    if ctx?.prefix.tag is prefix
      return ctx.module.match kind, arg 

    debug? "[match] check if one of current module's imports"
    imports = @root?.import ? []
    for m in imports when m.prefix.tag is prefix
      debug? "[match] checking #{m.module.tag}"
      return m.module.match kind, arg

toString (opts={})

The current Yang expression will covert back to the equivalent YANG schema text format.

At first glance, this may not seem like a useful facility since YANG schema text is generally known before parse but it becomes highly relevant when you consider a given Yang expression programatically changing via extends.

Currently it supports space parameter which can be used to specify number of spaces to use for indenting YANG statement blocks. It defaults to 2 but when set to 0, the generated output will omit newlines and other spacing for a more compact YANG output.

  toString: (opts={ space: 2 }) ->
    s = @kind
    if @source.argument?
      s += ' ' + switch @source.argument
        when 'value' then "'#{@tag}'"
        when 'text'
          "\n" + (indent '"'+@tag+'"', ' ', opts.space)
        else @tag
    sub =
      @elements
        .filter (x) => x.parent is this
        .map (x) -> x.toString opts
        .join "\n"
    if !!sub
      s += " {\n" + (indent sub, ' ', opts.space) + "\n}"
    else
      s += ';'
    return s

toJSON

The current Yang expression will convert into a simple JS object format.

  # toJSON() is inherited from Element

valueOf

The current 'Yang' expression will convert into a primitive form for comparision purposes.

  valueOf: ->
    switch @source.argument
      when 'value','text' then @tag.valueOf()
      else this

Please refer to Schema Conversion section of the Getting Started Guide for usage examples.

Export Yang Class

module.exports = Yang