From e588ba6e680c032a09d7efd1174c560c612feec6 Mon Sep 17 00:00:00 2001 From: Spotlight Date: Sun, 16 Aug 2020 23:06:09 -0500 Subject: [PATCH] Refactor HTTP handling logic This is an extraordinarily large commit, and in hindsight would have benefitied from being written separately. To summarise: - Converts namespace URI parsing to a regex, instead of convoluted string logic - Removes per-request XML structures in favor of normalised XPath queries - Moves common variables for all requests to a k/v map of their name and contents, in order to reduce function parameter count - Changes the name of the ECS action "NotifiedETicketsSynced" to its hardcoded (tenseless) name, "NotifyETicketsSynced" - Improved properly reporting errors to the end user in areas --- ecs.go | 87 ++++++++--------------------------- go.mod | 5 ++- go.sum | 13 ++++++ ias.go | 98 +++++++++++++++------------------------- main.go | 77 ++++++++++++++++++++++++++++--- structure.go | 69 ---------------------------- utils.go | 125 ++++++++++++++++++++++++++++++++++++++------------- 7 files changed, 238 insertions(+), 236 deletions(-) diff --git a/ecs.go b/ecs.go index e61af9d..fbd5680 100644 --- a/ecs.go +++ b/ecs.go @@ -18,43 +18,21 @@ package main import ( - "encoding/xml" "fmt" - "io/ioutil" - "net/http" - "strconv" - "time" + "github.com/antchfx/xmlquery" ) -func ecsHandler(w http.ResponseWriter, r *http.Request) { - // Figure out the action to handle via header. - action := r.Header.Get("SOAPAction") - action = parseAction(action, "ecs") +func ecsHandler(common map[string]string, doc *xmlquery.Node) (bool, string) { + timestamp := common["Timestamp"] - // Get a sexy new timestamp to use. - timestampNano := strconv.FormatInt(time.Now().UTC().Unix(), 10) - timestamp := timestampNano + "000" - - fmt.Println("[!] Incoming ECS request.") - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Error reading request body...", http.StatusInternalServerError) - } - - // The switch converts the HTTP Body of the request into a string. There is no need to convert the cases to byte format. - switch action { + // All actions below are for ECS-related functions. + switch common["Action"] { // TODO: Make the case functions cleaner. (e.g. Should the response be a variable?) // TODO: Update the responses so that they query the SQL Database for the proper information (e.g. Device Code, Token, etc). case "CheckDeviceStatus": - fmt.Println("CDS.") - CDS := CDS{} - if err = xml.Unmarshal(body, &CDS); err != nil { - fmt.Println("...or not. Bad or incomplete request. (End processing.)") - fmt.Fprint(w, "You need to POST some SOAP from WSC if you wanna get some, honey. ;3") - return - } - fmt.Println(CDS) + //You need to POST some SOAP from WSC if you wanna get some, honey. ;3 + fmt.Println("The request is valid! Responding...") custom := fmt.Sprintf(` 2018 @@ -63,47 +41,26 @@ func ecsHandler(w http.ResponseWriter, r *http.Request) { 0 %s %s`, timestamp, timestamp) - fmt.Fprint(w, formatSuccess("ecs", action, CDS.Version, CDS.DeviceID, CDS.MessageID, custom)) + return formatSuccess(common, custom) + + case "NotifyETicketsSynced": + // This is a disgusting request, but 20 dollars is 20 dollars. ;3 - case "NotifiedETicketsSynced": - fmt.Println("NETS") - NETS := NETS{} - if err = xml.Unmarshal(body, &NETS); err != nil { - fmt.Println("...or not. Bad or incomplete request. (End processing.)") - fmt.Fprint(w, "This is a disgusting request, but 20 dollars is 20 dollars. ;3") - fmt.Printf("Error: %v", err) - return - } - fmt.Println(NETS) fmt.Println("The request is valid! Responding...") - fmt.Fprint(w, formatSuccess("ecs", action, NETS.Version, NETS.DeviceID, NETS.MessageID, "")) + return formatSuccess(common, "") case "ListETickets": - fmt.Println("LET") - LET := LET{} - if err = xml.Unmarshal(body, &LET); err != nil { - fmt.Println("...or not. Bad or incomplete request. (End processing.)") - fmt.Fprint(w, "that's all you got for me? ;3") - fmt.Printf("Error: %v", err) - return - } - fmt.Println(LET) + // that's all you've got for me? ;3 + fmt.Println("The request is valid! Responding...") custom := fmt.Sprintf(`0 %s %s`, timestamp, timestamp) - fmt.Fprint(w, formatSuccess("ecs", action, LET.Version, LET.DeviceID, LET.MessageID, custom)) + return formatSuccess(common, custom) case "PurchaseTitle": - fmt.Println("PT") - PT := PT{} - if err = xml.Unmarshal(body, &PT); err != nil { - fmt.Println("...or not. Bad or incomplete request. (End processing.)") - fmt.Fprint(w, "if you wanna fun time, its gonna cost ya extra sweetie. ;3") - fmt.Printf("Error: %v", err) - return - } - fmt.Println(PT) + // If you wanna fun time, it's gonna cost ya extra sweetie... ;3 + fmt.Println("The request is valid! Responding...") custom := fmt.Sprintf(` 2018 @@ -119,15 +76,9 @@ func ecsHandler(w http.ResponseWriter, r *http.Request) { 00000000 00000000 00000000`, timestamp, timestamp) - fmt.Fprint(w, formatSuccess("ecs", action, PT.Version, PT.DeviceID, PT.MessageID, custom)) + return formatSuccess(common, custom) default: - fmt.Fprint(w, "WiiSOAP can't handle this. Try again later or actually use a Wii instead of a computer.") - return + return false, "WiiSOAP can't handle this. Try again later or actually use a Wii instead of a computer." } - - fmt.Println("Delivered response!") - - // TODO: Add NUS and CAS SOAP to the case list. - fmt.Println("[!] End of ECS Request.\n") } diff --git a/go.mod b/go.mod index e98f957..fe94377 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/morenatsu-net/WiiSOAP go 1.12 -require github.com/go-sql-driver/mysql v1.5.0 +require ( + github.com/antchfx/xmlquery v1.2.4 + github.com/go-sql-driver/mysql v1.5.0 +) diff --git a/go.sum b/go.sum index d314899..bc8c909 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,15 @@ +github.com/antchfx/xmlquery v1.2.4 h1:T/SH1bYdzdjTMoz2RgsfVKbM5uWh3gjDYYepFqQmFv4= +github.com/antchfx/xmlquery v1.2.4/go.mod h1:KQQuESaxSlqugE2ZBcM/qn+ebIpt+d+4Xx7YcSGAIrM= +github.com/antchfx/xpath v1.1.6 h1:6sVh6hB5T6phw1pFpHRQ+C4bd8sNI+O58flqtg7h0R0= +github.com/antchfx/xpath v1.1.6/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd h1:QPwSajcTUrFriMF1nJ3XzgoqakqQEsnZf9LdXdi2nkI= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/ias.go b/ias.go index 6896f10..f813e8d 100644 --- a/ias.go +++ b/ias.go @@ -18,53 +18,39 @@ package main import ( - "encoding/xml" "fmt" - "io/ioutil" - "net/http" + "github.com/antchfx/xmlquery" ) -func iasHandler(w http.ResponseWriter, r *http.Request) { - // Figure out the action to handle via header. - action := r.Header.Get("SOAPAction") - action = parseAction(action, "ias") +func iasHandler(common map[string]string, doc *xmlquery.Node) (bool, string) { - fmt.Println("[!] Incoming IAS request.") - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Error reading request body...", http.StatusInternalServerError) - } - - // The switch converts the HTTP Body of the request into a string. There is no need to convert the cases to byte format. - switch action { + // All actions below are for IAS-related functions. + switch common["Action"] { // TODO: Make the case functions cleaner. (e.g. Should the response be a variable?) // TODO: Update the responses so that they query the SQL Database for the proper information (e.g. Device Code, Token, etc). case "CheckRegistration": - fmt.Println("CR.") - CR := CR{} - if err = xml.Unmarshal(body, &CR); err != nil { - fmt.Println("...or not. Bad or incomplete request. (End processing.)") - fmt.Fprint(w, "not good enough for me. ;3") - fmt.Printf("Error: %v", err) - return + serialNo, err := getKey(doc, "SerialNumber") + if err != nil { + return formatError(common, 5, "not good enough for me. ;3", err) } - fmt.Println(CR) + fmt.Println("The request is valid! Responding...") custom := fmt.Sprintf(`%s - R`, CR.SerialNo) - fmt.Fprint(w, formatSuccess("ias", action, CR.Version, CR.DeviceID, CR.MessageID, custom)) + R`, serialNo) + return formatSuccess(common, custom) case "GetRegistrationInfo": - fmt.Println("GRI.") - GRI := GRI{} - if err = xml.Unmarshal(body, &GRI); err != nil { - fmt.Println("...or not. Bad or incomplete request. (End processing.)") - fmt.Fprint(w, "how dirty. ;3") - fmt.Printf("Error: %v", err) - return + accountId, err := getKey(doc, "AccountId") + if err != nil { + return formatError(common, 6, "how dirty. ;3", err) } - fmt.Println(GRI) + + country, err := getKey(doc, "Country") + if err != nil { + return formatError(common, 6, "how dirty. ;3", err) + } + fmt.Println("The request is valid! Responding...") custom := fmt.Sprintf(`%s 00000000 @@ -73,47 +59,35 @@ func iasHandler(w http.ResponseWriter, r *http.Request) { 0000000000000000 R - POINTS`, GRI.AccountID, GRI.Country) - fmt.Fprint(w, formatSuccess("ias", action, GRI.Version, GRI.DeviceID, GRI.MessageID, custom)) + POINTS`, accountId, country) + return formatSuccess(common, custom) case "Register": - fmt.Println("REG.") - REG := REG{} - if err = xml.Unmarshal(body, ®); err != nil { - fmt.Println("...or not. Bad or incomplete request. (End processing.)") - fmt.Fprint(w, "disgustingly invalid. ;3") - fmt.Printf("Error: %v", err) - return + accountId, err := getKey(doc, "AccountId") + if err != nil { + return formatError(common, 7, "disgustingly invalid. ;3", err) } - fmt.Println(REG) + + country, err := getKey(doc, "Country") + if err != nil { + return formatError(common, 7, "disgustingly invalid. ;3", err) + } + fmt.Println("The request is valid! Responding...") custom := fmt.Sprintf(`%s 00000000 %s - 00000000`, REG.AccountID, REG.Country) - fmt.Fprint(w, formatSuccess("ias", action, REG.Version, REG.DeviceID, REG.MessageID, custom)) + 00000000`, accountId, country) + return formatSuccess(common, custom) case "Unregister": - fmt.Println("UNR.") - UNR := UNR{} - if err = xml.Unmarshal(body, &UNR); err != nil { - fmt.Println("...or not. Bad or incomplete request. (End processing.)") - fmt.Fprint(w, "how abnormal... ;3") - fmt.Printf("Error: %v", err) - return - } - fmt.Println(UNR) + // how abnormal... ;3 + fmt.Println("The request is valid! Responding...") - fmt.Fprint(w, formatSuccess("ias", action, UNR.Version, UNR.DeviceID, UNR.MessageID, "")) + return formatSuccess(common, "") default: - fmt.Fprint(w, "WiiSOAP can't handle this. Try again later or actually use a Wii instead of a computer.") - return + return false, "WiiSOAP can't handle this. Try again later or actually use a Wii instead of a computer." } - - fmt.Println("Delivered response!") - - // TODO: Add NUS and CAS SOAP to the case list. - fmt.Println("[!] End of IAS Request.\n") } diff --git a/main.go b/main.go index 3f20257..ebab5cc 100644 --- a/main.go +++ b/main.go @@ -21,11 +21,11 @@ import ( "database/sql" "encoding/xml" "fmt" + _ "github.com/go-sql-driver/mysql" "io/ioutil" "log" "net/http" - - _ "github.com/go-sql-driver/mysql" + "strings" ) const ( @@ -45,7 +45,7 @@ const ( %d false` + "\n" // Footer is the base format of a closing envelope in SOAP. - Footer = ` + Footer = ` ` ) @@ -84,9 +84,76 @@ func main() { // These following endpoints don't have to match what the official WSC have. // However, semantically, it feels proper. - http.HandleFunc("/ecs/services/ECommerceSOAP", ecsHandler) // For ECS operations - http.HandleFunc("/ias/services/IdentityAuthenticationSOAP", iasHandler) // For IAS operations + http.HandleFunc("/ecs/services/ECommerceSOAP", commonHandler) + http.HandleFunc("/ias/services/IdentityAuthenticationSOAP", commonHandler) log.Fatal(http.ListenAndServe(CON.Address, nil)) // From here on out, all special cool things should go into their respective handler function. } + +func commonHandler(w http.ResponseWriter, r *http.Request) { + // Figure out the action to handle via header. + service, action := parseAction(r.Header.Get("SOAPAction")) + if service == "" || action == "" { + printError(w, "Error interpreting request...") + return + } + + // Verify this is a service type we know. + switch service { + case "ecs": + case "ias": + break + default: + printError(w, "Unsupported service type...") + return + } + + fmt.Println("[!] Incoming " + strings.ToUpper(service) + " request - handling for " + action) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + printError(w, "Error reading request body...") + return + } + + // Tidy up parsed document for easier usage going forward. + doc, err := normalise(service, action, strings.NewReader(string(body))) + if err != nil { + printError(w, "Error interpreting request body: "+err.Error()) + return + } + + // Extract shared values from this request. + common, err := obtainCommon(doc) + if err != nil { + printError(w, "Error handling request body: "+err.Error()) + return + } + + // Insert the current action being performed. + common["Service"] = service + common["Action"] = action + + var result string + var successful bool + if service == "ias" { + successful, result = iasHandler(common, doc) + } else if service == "ecs" { + successful, result = ecsHandler(common, doc) + } + + if successful { + // Write returned with proper Content-Type + w.Header().Set("Content-Type", "text/xml; charset=utf-8") + w.Write([]byte(result)) + } else { + printError(w, result) + } + + fmt.Println("[!] End of " + strings.ToUpper(service) + " Request.\n") +} + +func printError(w http.ResponseWriter, reason string) { + http.Error(w, reason, http.StatusInternalServerError) + log.Println("Failed to handle request: " + reason) +} diff --git a/structure.go b/structure.go index eaf9af5..9e88b6c 100644 --- a/structure.go +++ b/structure.go @@ -35,72 +35,3 @@ type Config struct { SQLPass string `xml:"SQLPass"` SQLDB string `xml:"SQLDB"` } - -// CDS - CheckDeviceStatus -type CDS struct { - XMLName xml.Name `xml:"Envelope"` - Version string `xml:"Body>CheckDeviceStatus>Version"` - DeviceID string `xml:"Body>CheckDeviceStatus>DeviceId"` - MessageID string `xml:"Body>CheckDeviceStatus>MessageId"` -} - -// NETS - NotifiedETicketsSynced -type NETS struct { - XMLName xml.Name `xml:"Envelope"` - Version string `xml:"Body>NotifiedETicketsSynced>Version"` - DeviceID string `xml:"Body>NotifiedETicketsSynced>DeviceId"` - MessageID string `xml:"Body>NotifiedETicketsSynced>MessageId"` -} - -// LET - ListETickets -type LET struct { - XMLName xml.Name `xml:"Envelope"` - Version string `xml:"Body>ListETickets>Version"` - DeviceID string `xml:"Body>ListETickets>DeviceId"` - MessageID string `xml:"Body>ListETickets>MessageId"` -} - -// PT - PurchaseTitle -type PT struct { - XMLName xml.Name `xml:"Envelope"` - Version string `xml:"Body>PurchaseTitle>Version"` - DeviceID string `xml:"Body>PurchaseTitle>DeviceId"` - MessageID string `xml:"Body>PurchaseTitle>MessageId"` -} - -// CR - CheckRegistration -type CR struct { - XMLName xml.Name `xml:"Envelope"` - Version string `xml:"Body>CheckRegistration>Version"` - DeviceID string `xml:"Body>CheckRegistration>DeviceId"` - MessageID string `xml:"Body>CheckRegistration>MessageId"` - SerialNo string `xml:"Body>CheckRegistration>SerialNumber"` -} - -// GRI - GetRegistrationInfo -type GRI struct { - XMLName xml.Name `xml:"Envelope"` - Version string `xml:"Body>GetRegistrationInfo>Version"` - DeviceID string `xml:"Body>GetRegistrationInfo>DeviceId"` - MessageID string `xml:"Body>GetRegistrationInfo>MessageId"` - AccountID string `xml:"Body>GetRegistrationInfo>AccountId"` - Country string `xml:"Body>GetRegistrationInfo>Country"` -} - -// REG - Register -type REG struct { - XMLName xml.Name `xml:"Envelope"` - Version string `xml:"Body>Register>Version"` - DeviceID string `xml:"Body>Register>DeviceId"` - MessageID string `xml:"Body>Register>MessageId"` - AccountID string `xml:"Body>Register>AccountId"` - Country string `xml:"Body>Register>Country"` -} - -// UNR - Unregister -type UNR struct { - XMLName xml.Name `xml:"Envelope"` - Version string `xml:"Body>Unregister>Version"` - DeviceID string `xml:"Body>Unregister>DeviceId"` - MessageID string `xml:"Body>Unregister>MessageId"` -} diff --git a/utils.go b/utils.go index 376c9c3..b0b944c 100644 --- a/utils.go +++ b/utils.go @@ -18,42 +18,40 @@ package main import ( + "errors" "fmt" + "github.com/antchfx/xmlquery" + "io" + "regexp" "strconv" - "strings" "time" ) -// namespaceForType returns the expected XML namespace format for a service. -func namespaceForType(service string) string { - return "urn:" + service + ".wsapi.broadon.com" -} +var namespaceParse = regexp.MustCompile(`^urn:(.{3})\.wsapi\.broadon\.com/(.*)$`) -// parseAction interprets contents along the lines of "urn:ecs.wsapi.broadon.com/CheckDeviceStatus". -func parseAction(original string, service string) string { - prefix := namespaceForType(service) + "/" - stripped := strings.Replace(original, prefix, "", 1) - - if stripped == original { - // This doesn't appear valid. - return "" - } else { - return stripped +// parseAction interprets contents along the lines of "urn:ecs.wsapi.broadon.com/CheckDeviceStatus", +//where "CheckDeviceStatus" is the action to be performed. +func parseAction(original string) (string, string) { + // Intended to return the original string, the service's name and the name of the action. + matches := namespaceParse.FindStringSubmatch(original) + if len(matches) != 3 { + // It seems like the passed action was not matched properly. + return "", "" } + + service := matches[1] + action := matches[2] + return service, action } // formatHeader formats a response type and the proper service. func formatHeader(responseType string, service string) string { - return fmt.Sprintf(Header, responseType, namespaceForType(service)) + return fmt.Sprintf(Header, responseType, "urn:"+service+".wsapi.broadon.com") } // formatTemplate inserts common, cross-requests values into every request. -func formatTemplate(version string, deviceId string, messageId string, errorCode int) string { - // Get a sexy new timestamp to use. - timestampNano := strconv.FormatInt(time.Now().UTC().Unix(), 10) - timestamp := timestampNano + "000" - - return fmt.Sprintf(Template, version, deviceId, messageId, timestamp, errorCode) +func formatTemplate(common map[string]string, errorCode int) string { + return fmt.Sprintf(Template, common["Version"], common["DeviceID"], common["MessageId"], common["Timestamp"], errorCode) } // formatFooter formats the closing tags of any SOAP request per previous response type. @@ -62,21 +60,86 @@ func formatFooter(responseType string) string { } // formatForNamespace mangles together several variables throughout a SOAP request. -func formatForNamespace(service string, responseType string, version string, deviceId string, messageId string, errorCode int, extraContents string) string { +func formatForNamespace(common map[string]string, errorCode int, extraContents string) string { return fmt.Sprintf("%s%s%s%s", - formatHeader(responseType, service), - formatTemplate(version, deviceId, messageId, errorCode), - "\t\t"+extraContents, - formatFooter(responseType), + formatHeader(common["Action"], common["Service"]), + formatTemplate(common, errorCode), + "\t"+extraContents+"\n", + formatFooter(common["Action"]), ) } // formatSuccess returns a standard SOAP response with a positive error code, and additional contents. -func formatSuccess(service string, responseType string, version string, deviceId string, messageId string, extraContents string) string { - return formatForNamespace(service, responseType, version, deviceId, messageId, 0, extraContents) +func formatSuccess(common map[string]string, extraContents string) (bool, string) { + return true, formatForNamespace(common, 0, extraContents) } // formatError returns a standard SOAP response with an error code. -func formatError(service string, responseType string, version string, deviceId string, messageId string, errorCode int) string { - return formatForNamespace(service, responseType, version, deviceId, messageId, errorCode, "") +func formatError(common map[string]string, errorCode int, reason string, err error) (bool, string) { + extra := "" + reason + "\n" + + "\t" + err.Error() + "\n" + return false, formatForNamespace(common, errorCode, extra) +} + +// normalise parses a document, returning a document with only the request type's child nodes, stripped of prefix. +func normalise(service string, action string, reader io.Reader) (*xmlquery.Node, error) { + doc, err := xmlquery.Parse(reader) + if err != nil { + return nil, err + } + + // Find the keys for this element named after the action. + result := doc.SelectElement("//" + service + ":" + action) + if result == nil { + return nil, errors.New("missing root node") + } + stripNamespace(result) + + return result, nil +} + +// stripNamespace removes a prefix from nodes, changing a key from "ias:Version" to "Version". +// It is based off of https://github.com/antchfx/xmlquery/issues/15#issuecomment-567575075. +func stripNamespace(node *xmlquery.Node) { + node.Prefix = "" + + for child := node.FirstChild; child != nil; child = child.NextSibling { + stripNamespace(child) + } +} + +// obtainCommon interprets a given node, and from its children finds common keys and respective values across all requests. +func obtainCommon(doc *xmlquery.Node) (map[string]string, error) { + info := make(map[string]string) + + // Get a sexy new timestamp to use. + timestampNano := strconv.FormatInt(time.Now().UTC().Unix(), 10) + info["Timestamp"] = timestampNano + "000" + + // These fields are common across all requests. + // Looping through all... + shared := []string{"Version", "DeviceId", "MessageId"} + for _, key := range shared { + // select their node by name... + value, err := getKey(doc, key) + if err != nil { + return nil, err + } + + // and insert their value to our map. + info[key] = value + } + + return info, nil +} + +// getKey returns the value for a child key from a node, if documented. +func getKey(doc *xmlquery.Node, key string) (string, error) { + node := xmlquery.FindOne(doc, "//"+key) + + if node == nil { + return "", errors.New("missing mandatory key named " + key) + } else { + return node.InnerText(), nil + } }