lbcd/v2/btcjson/help.go

563 lines
18 KiB
Go
Raw Normal View History

Reimagine btcjson package with version 2. This commit implements a reimagining of the way the btcjson package functions based upon how the project has evolved and lessons learned while using it since it was first written. It therefore contains significant changes to the API. For now, it has been implemented in a v2 subdirectory to prevent breaking existing callers, but the ultimate goal is to update all callers to use the new version and then to replace the old API with the new one. This also removes the need for the btcws completely since those commands have been rolled in. The following is an overview of the changes and some reasoning behind why they were made: - The infrastructure has been completely changed to be reflection based instead of requiring thousands and thousands of lines of manual, and therefore error prone, marshal/unmarshal code - This makes it much easier to add new commands without making marshalling mistakes since it is simply a struct definition and a call to register that new struct (plus a trivial New<foo>Cmd function and tests, of course) - It also makes it much easier to gain a lot of information from simply looking at the struct definition which was previously not possible such as the order of the parameters, which parameters are required versus optional, and what the default values for optional parameters are - Each command now has usage flags associated with them that can be queried which are intended to allow classification of the commands such as for chain server and wallet server and websocket-only - The help infrastructure has been completely redone to provide automatic generation with caller provided description map and result types. This is in contrast to the previous method of providing the help directly which meant it would end up in the binary of anything that imported the package - Many of the structs have been renamed to use the terminology from the JSON-RPC specification: - RawCmd/Message is now only a single struct named Request to reflect the fact it is a JSON-RPC request - Error is now called RPCError to reflect the fact it is specifically an RPC error as opposed to many of the other errors that are possible - All RPC error codes except the standard JSON-RPC 2.0 errors have been converted from full structs to only codes since an audit of the codebase has shown that the messages are overridden the vast majority of the time with specifics (as they should be) and removing them also avoids the temptation to return non-specific, and therefore not as helpful, error messages - There is now an Error which provides a type assertable error with error codes so callers can better ascertain failure reasons programatically - The ID is no longer a part of the command and is instead specified at the time the command is marshalled into a JSON-RPC request. This aligns better with the way JSON-RPC functions since it is the caller who manages the ID that is sent with any given _request_, not the package - All <Foo>Cmd structs now treat non-pointers as required fields and pointers as optional fields - All New<Foo>Cmd functions now accept the exact number of parameters, with pointers to the appropriate type for optional parameters - This is preferrable to the old vararg syntax since it means the code will fail to compile if the optional arguments are changed now which helps prevent errors creep in over time from missed modifications to optional args - All of the connection related code has been completely eliminated since this package is not intended to used a client, rather it is intended to provide the infrastructure needed to marshal/unmarshal Bitcoin-specific JSON-RPC requests and replies from static types - The btcrpcclient package provides a robust client with connection management and higher-level types that in turn uses the primitives provided by this package - Even if the caller does not wish to use btcrpcclient for some reason, they should still be responsible for connection management since they might want to use any number of connection features which the package would not necessarily support - Synced a few of the commands that have added new optional fields that have since been added to Bitcoin Core - Includes all of the commands and notifications that were previously in btcws - Now provides 100% test coverage with parallel tests - The code is completely golint and go vet clean This has the side effect of addressing nearly everything in, and therefore closes #26. Also fixes #18 and closes #19.
2014-12-31 08:05:03 +01:00
// Copyright (c) 2015 Conformal Systems LLC.
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.
package btcjson
import (
"bytes"
"fmt"
"reflect"
"strings"
"text/tabwriter"
)
// baseHelpDescs house the various help labels, types, and example values used
// when generating help. The per-command synopsis, field descriptions,
// conditions, and result descriptions are to be provided by the caller.
var baseHelpDescs = map[string]string{
// Misc help labels and output.
"help-arguments": "Arguments",
"help-arguments-none": "None",
"help-result": "Result",
"help-result-nothing": "Nothing",
"help-default": "default",
"help-optional": "optional",
"help-required": "required",
// JSON types.
"json-type-numeric": "numeric",
"json-type-string": "string",
"json-type-bool": "boolean",
"json-type-array": "array of ",
"json-type-object": "object",
"json-type-value": "value",
// JSON examples.
"json-example-string": "value",
"json-example-bool": "true|false",
"json-example-map-data": "data",
"json-example-unknown": "unknown",
}
// descLookupFunc is a function which is used to lookup a description given
// a key.
type descLookupFunc func(string) string
// reflectTypeToJSONType returns a string that represents the JSON type
// associated with the provided Go type.
func reflectTypeToJSONType(xT descLookupFunc, rt reflect.Type) string {
kind := rt.Kind()
if isNumeric(kind) {
return xT("json-type-numeric")
}
switch kind {
case reflect.String:
return xT("json-type-string")
case reflect.Bool:
return xT("json-type-bool")
case reflect.Array, reflect.Slice:
return xT("json-type-array") + reflectTypeToJSONType(xT,
rt.Elem())
case reflect.Struct:
return xT("json-type-object")
case reflect.Map:
return xT("json-type-object")
}
return xT("json-type-value")
}
// resultStructHelp returns a slice of strings containing the result help output
// for a struct. Each line makes use of tabs to separate the relevant pieces so
// a tabwriter can be used later to line everything up. The descriptions are
// pulled from the active help descriptions map based on the lowercase version
// of the provided reflect type and json name (or the lowercase version of the
// field name if no json tag was specified).
func resultStructHelp(xT descLookupFunc, rt reflect.Type, indentLevel int) []string {
indent := strings.Repeat(" ", indentLevel)
typeName := strings.ToLower(rt.Name())
// Generate the help for each of the fields in the result struct.
numField := rt.NumField()
results := make([]string, 0, numField)
for i := 0; i < numField; i++ {
rtf := rt.Field(i)
// The field name to display is the json name when it's
// available, otherwise use the lowercase field name.
var fieldName string
if tag := rtf.Tag.Get("json"); tag != "" {
fieldName = strings.Split(tag, ",")[0]
} else {
fieldName = strings.ToLower(rtf.Name)
}
// Deference pointer if needed.
rtfType := rtf.Type
if rtfType.Kind() == reflect.Ptr {
rtfType = rtf.Type.Elem()
}
// Generate the JSON example for the result type of this struct
// field. When it is a complex type, examine the type and
// adjust the opening bracket and brace combination accordingly.
fieldType := reflectTypeToJSONType(xT, rtfType)
fieldDescKey := typeName + "-" + fieldName
fieldExamples, isComplex := reflectTypeToJSONExample(xT,
rtfType, indentLevel, fieldDescKey)
if isComplex {
var brace string
kind := rtfType.Kind()
if kind == reflect.Array || kind == reflect.Slice {
brace = "[{"
} else {
brace = "{"
}
result := fmt.Sprintf("%s\"%s\": %s\t(%s)\t%s", indent,
fieldName, brace, fieldType, xT(fieldDescKey))
results = append(results, result)
for _, example := range fieldExamples {
results = append(results, example)
}
} else {
result := fmt.Sprintf("%s\"%s\": %s,\t(%s)\t%s", indent,
fieldName, fieldExamples[0], fieldType,
xT(fieldDescKey))
results = append(results, result)
}
}
return results
}
// reflectTypeToJSONExample generates example usage in the format used by the
// help output. It handles arrays, slices and structs recursively. The output
// is returned as a slice of lines so the final help can be nicely aligned via
// a tab writer. A bool is also returned which specifies whether or not the
// type results in a complex JSON object since they need to be handled
// differently.
func reflectTypeToJSONExample(xT descLookupFunc, rt reflect.Type, indentLevel int, fieldDescKey string) ([]string, bool) {
// Indirect pointer if needed.
if rt.Kind() == reflect.Ptr {
rt = rt.Elem()
}
kind := rt.Kind()
if isNumeric(kind) {
if kind == reflect.Float32 || kind == reflect.Float64 {
return []string{"n.nnn"}, false
}
return []string{"n"}, false
}
switch kind {
case reflect.String:
return []string{`"` + xT("json-example-string") + `"`}, false
case reflect.Bool:
return []string{xT("json-example-bool")}, false
case reflect.Struct:
indent := strings.Repeat(" ", indentLevel)
results := resultStructHelp(xT, rt, indentLevel+1)
// An opening brace is needed for the first indent level. For
// all others, it will be included as a part of the previous
// field.
if indentLevel == 0 {
newResults := make([]string, len(results)+1)
newResults[0] = "{"
copy(newResults[1:], results)
results = newResults
}
// The closing brace has a comma after it except for the first
// indent level. The final tabs are necessary so the tab writer
// lines things up properly.
closingBrace := indent + "}"
if indentLevel > 0 {
closingBrace += ","
}
results = append(results, closingBrace+"\t\t")
return results, true
case reflect.Array, reflect.Slice:
results, isComplex := reflectTypeToJSONExample(xT, rt.Elem(),
indentLevel, fieldDescKey)
// When the result is complex, it is because this is an array of
// objects.
if isComplex {
// When this is at indent level zero, there is no
// previous field to house the opening array bracket, so
// replace the opening object brace with the array
// syntax. Also, replace the final closing object brace
// with the variadiac array closing syntax.
indent := strings.Repeat(" ", indentLevel)
if indentLevel == 0 {
results[0] = indent + "[{"
results[len(results)-1] = indent + "},...]"
return results, true
}
// At this point, the indent level is greater than 0, so
// the opening array bracket and object brace are
// already a part of the previous field. However, the
// closing entry is a simple object brace, so replace it
// with the variadiac array closing syntax. The final
// tabs are necessary so the tab writer lines things up
// properly.
results[len(results)-1] = indent + "},...],\t\t"
return results, true
}
// It's an array of primitives, so return the formatted text
// accordingly.
return []string{fmt.Sprintf("[%s,...]", results[0])}, false
case reflect.Map:
indent := strings.Repeat(" ", indentLevel)
results := make([]string, 0, 3)
// An opening brace is needed for the first indent level. For
// all others, it will be included as a part of the previous
// field.
if indentLevel == 0 {
results = append(results, indent+"{")
}
// Maps are a bit special in that they need to have the key,
// value, and description of the object entry specifically
// called out.
innerIndent := strings.Repeat(" ", indentLevel+1)
result := fmt.Sprintf("%s%q: %s, (%s) %s", innerIndent,
xT(fieldDescKey+"--key"), xT(fieldDescKey+"--value"),
reflectTypeToJSONType(xT, rt), xT(fieldDescKey+"--desc"))
results = append(results, result)
results = append(results, innerIndent+"...")
results = append(results, indent+"}")
return results, true
}
return []string{xT("json-example-unknown")}, false
}
// resultTypeHelp generates and returns formatted help for the provided result
// type.
func resultTypeHelp(xT descLookupFunc, rt reflect.Type, fieldDescKey string) string {
// Generate the JSON example for the result type.
results, isComplex := reflectTypeToJSONExample(xT, rt, 0, fieldDescKey)
// When this is a primitive type, add the associated JSON type and
// result description into the final string, format it accordingly,
// and return it.
if !isComplex {
return fmt.Sprintf("%s (%s) %s", results[0],
reflectTypeToJSONType(xT, rt), xT(fieldDescKey))
}
// At this point, this is a complex type that already has the JSON types
// and descriptions in the results. Thus, use a tab writer to nicely
// align the help text.
var formatted bytes.Buffer
w := new(tabwriter.Writer)
w.Init(&formatted, 0, 4, 1, ' ', 0)
for i, text := range results {
if i == len(results)-1 {
fmt.Fprintf(w, text)
} else {
fmt.Fprintln(w, text)
}
}
w.Flush()
return formatted.String()
}
// argTypeHelp returns the type of provided command argument as a string in the
// format used by the help output. In particular, it includes the JSON type
// (boolean, numeric, string, array, object) along with optional and the default
// value if applicable.
func argTypeHelp(xT descLookupFunc, structField reflect.StructField, defaultVal *reflect.Value) string {
// Indirect the pointer if needed and track if it's an optional field.
fieldType := structField.Type
var isOptional bool
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
isOptional = true
}
// When there is a default value, it must also be a pointer due to the
// rules enforced by RegisterCmd.
if defaultVal != nil {
indirect := defaultVal.Elem()
defaultVal = &indirect
}
// Convert the field type to a JSON type.
details := make([]string, 0, 3)
details = append(details, reflectTypeToJSONType(xT, fieldType))
// Add optional and default value to the details if needed.
if isOptional {
details = append(details, xT("help-optional"))
// Add the default value if there is one. This is only checked
// when the field is optional since a non-optional field can't
// have a default value.
if defaultVal != nil {
val := defaultVal.Interface()
if defaultVal.Kind() == reflect.String {
val = fmt.Sprintf(`"%s"`, val)
}
str := fmt.Sprintf("%s=%v", xT("help-default"), val)
details = append(details, str)
}
} else {
details = append(details, xT("help-required"))
}
return strings.Join(details, ", ")
}
// argHelp generates and returns formatted help for the provided command.
func argHelp(xT descLookupFunc, rtp reflect.Type, defaults map[int]reflect.Value, method string) string {
// Return now if the command has no arguments.
rt := rtp.Elem()
numFields := rt.NumField()
if numFields == 0 {
return ""
}
// Generate the help for each argument in the command. Several
// simplifying assumptions are made here because the RegisterCmd
// function has already rigorously enforced the layout.
args := make([]string, 0, numFields)
for i := 0; i < numFields; i++ {
rtf := rt.Field(i)
var defaultVal *reflect.Value
if defVal, ok := defaults[i]; ok {
defaultVal = &defVal
}
fieldName := strings.ToLower(rtf.Name)
helpText := fmt.Sprintf("%d.\t%s\t(%s)\t%s", i+1, fieldName,
argTypeHelp(xT, rtf, defaultVal),
xT(method+"-"+fieldName))
args = append(args, helpText)
// For types which require a JSON object, or an array of JSON
// objects, generate the full syntax for the argument.
fieldType := rtf.Type
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
kind := fieldType.Kind()
switch kind {
case reflect.Struct:
fieldDescKey := fmt.Sprintf("%s-%s", method, fieldName)
resultText := resultTypeHelp(xT, fieldType, fieldDescKey)
args = append(args, resultText)
case reflect.Map:
fieldDescKey := fmt.Sprintf("%s-%s", method, fieldName)
resultText := resultTypeHelp(xT, fieldType, fieldDescKey)
args = append(args, resultText)
case reflect.Array, reflect.Slice:
fieldDescKey := fmt.Sprintf("%s-%s", method, fieldName)
if rtf.Type.Elem().Kind() == reflect.Struct {
resultText := resultTypeHelp(xT, fieldType,
fieldDescKey)
args = append(args, resultText)
}
}
}
// Add argument names, types, and descriptions if there are any. Use a
// tab writer to nicely align the help text.
var formatted bytes.Buffer
w := new(tabwriter.Writer)
w.Init(&formatted, 0, 4, 1, ' ', 0)
for _, text := range args {
fmt.Fprintln(w, text)
}
w.Flush()
return formatted.String()
}
// methodHelp generates and returns the help output for the provided command
// and method info. This is the main work horse for the exported MethodHelp
// function.
func methodHelp(xT descLookupFunc, rtp reflect.Type, defaults map[int]reflect.Value, method string, resultTypes []interface{}) string {
// Start off with the method usage and help synopsis.
help := fmt.Sprintf("%s\n\n%s\n", methodUsageText(rtp, defaults, method),
xT(method+"--synopsis"))
// Generate the help for each argument in the command.
if argText := argHelp(xT, rtp, defaults, method); argText != "" {
help += fmt.Sprintf("\n%s:\n%s", xT("help-arguments"),
argText)
} else {
help += fmt.Sprintf("\n%s:\n%s\n", xT("help-arguments"),
xT("help-arguments-none"))
}
// Generate the help text for each result type.
resultTexts := make([]string, 0, len(resultTypes))
for i := range resultTypes {
rtp := reflect.TypeOf(resultTypes[i])
fieldDescKey := fmt.Sprintf("%s--result%d", method, i)
if resultTypes[i] == nil {
resultText := xT("help-result-nothing")
resultTexts = append(resultTexts, resultText)
continue
}
resultText := resultTypeHelp(xT, rtp.Elem(), fieldDescKey)
resultTexts = append(resultTexts, resultText)
}
// Add result types and descriptions. When there is more than one
// result type, also add the condition which triggers it.
if len(resultTexts) > 1 {
for i, resultText := range resultTexts {
condKey := fmt.Sprintf("%s--condition%d", method, i)
help += fmt.Sprintf("\n%s (%s):\n%s\n",
xT("help-result"), xT(condKey), resultText)
}
} else if len(resultTexts) > 0 {
help += fmt.Sprintf("\n%s:\n%s\n", xT("help-result"),
resultTexts[0])
} else {
help += fmt.Sprintf("\n%s:\n%s\n", xT("help-result"),
xT("help-result-nothing"))
}
return help
}
// isValidResultType returns whether the passed reflect kind is one of the
// acceptable types for results.
func isValidResultType(kind reflect.Kind) bool {
if isNumeric(kind) {
return true
}
switch kind {
case reflect.String, reflect.Struct, reflect.Array, reflect.Slice,
reflect.Bool, reflect.Map:
return true
}
return false
}
// GenerateHelp generates and returns help output for the provided method and
// result types given a map to provide the appropriate keys for the method
// synopsis, field descriptions, conditions, and result descriptions. The
// method must be associated with a registered type. All commands provided by
// this package are registered by default.
//
// The resultTypes must be pointer-to-types which represent the specific types
// of values the command returns. For example, if the command only returns a
// boolean value, there should only be a single entry of (*bool)(nil). Note
// that each type must be a single pointer to the type. Therefore, it is
// recommended to simply pass a nil pointer cast to the appropriate type as
// previously shown.
//
// The provided descriptions map must contain all of the keys or an error will
// be returned which includes the missing key, or the final missing key when
// there is more than one key missing. The generated help in the case of such
// an error will use the key in place of the description.
//
// The following outlines the required keys:
// - "<method>--synopsis" Synopsis for the command
// - "<method>-<lowerfieldname>" Description for each command argument
// - "<typename>-<lowerfieldname>" Description for each object field
// - "<method>--condition<#>" Description for each result condition
// - "<method>--result<#>" Description for each primitive result num
//
// Notice that the "special" keys synopsis, condition<#>, and result<#> are
// preceded by a double dash to ensure they don't conflict with field names.
//
// The condition keys are only required when there is more than on result type,
// and the result key for a given result type is only required if it's not an
// object.
//
// For example, consider the 'help' command itself. There are two possible
// returns depending on the provided parameters. So, the help would be
// generated by calling the function as follows
// GenerateHelp("help", descs, (*string)(nil), (*string)(nil)).
//
// The following keys would then be required in the provided descriptions map:
//
// - "help--synopsis": "Returns a list of all commands or help for ...."
// - "help-command": "The command to retrieve help for",
// - "help--condition0": "no command provided"
// - "help--condition1": "command specified"
// - "help--result0": "List of commands"
// - "help--result1": "Help for specified command"
func GenerateHelp(method string, descs map[string]string, resultTypes ...interface{}) (string, error) {
// Look up details about the provided method and error out if not
// registered.
registerLock.RLock()
rtp, ok := methodToConcreteType[method]
info := methodToInfo[method]
registerLock.RUnlock()
if !ok {
str := fmt.Sprintf("%q is not registered", method)
return "", makeError(ErrUnregisteredMethod, str)
}
// Validate each result type is a pointer to a supported type (or nil).
for i, resultType := range resultTypes {
if resultType == nil {
continue
}
rtp := reflect.TypeOf(resultType)
if rtp.Kind() != reflect.Ptr {
str := fmt.Sprintf("result #%d (%v) is not a pointer",
i, rtp.Kind())
return "", makeError(ErrInvalidType, str)
}
elemKind := rtp.Elem().Kind()
if !isValidResultType(elemKind) {
str := fmt.Sprintf("result #%d (%v) is not an allowed "+
"type", i, elemKind)
return "", makeError(ErrInvalidType, str)
}
}
// Create a closure for the description lookup function which falls back
// to the base help descritptions map for unrecognized keys and tracks
// and missing keys.
var missingKey string
xT := func(key string) string {
if desc, ok := descs[key]; ok {
return desc
}
if desc, ok := baseHelpDescs[key]; ok {
return desc
}
missingKey = key
return key
}
// Generate and return the help for the method.
help := methodHelp(xT, rtp, info.defaults, method, resultTypes)
if missingKey != "" {
return help, makeError(ErrMissingDescription, missingKey)
}
return help, nil
}