├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config.nims ├── src └── xmltools.nim ├── tests └── test_all.nim └── xmltools.nimble /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | bin -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: c 2 | 3 | env: 4 | # Nim versions to test against 5 | - CHOOSENIM_CHOOSE_VERSION=devel 6 | - CHOOSENIM_CHOOSE_VERSION=0.19.4 7 | - CHOOSENIM_CHOOSE_VERSION=0.19.0 8 | 9 | matrix: 10 | allow_failures: 11 | # devel branch is often broken 12 | - env: CHOOSENIM_CHOOSE_VERSION=devel 13 | 14 | install: 15 | - curl https://nim-lang.org/choosenim/init.sh -sSf | sh -s -- -y 16 | - export PATH=~/.nimble/bin:$PATH 17 | - nimble update 18 | - nimble install -d -y 19 | 20 | before_script: 21 | - set -e 22 | - export PATH=~/.nimble/bin:$PATH 23 | - export CHOOSENIM_NO_ANALYTICS=1 24 | script: 25 | - nim test 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Anatoly Galiulin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xmltools [![nimble](https://raw.githubusercontent.com/yglukhov/nimble-tag/master/nimble.png)](https://github.com/yglukhov/nimble-tag) 2 | 3 | [![Build Status](https://travis-ci.org/vegansk/xmltools.svg?branch=master)](https://travis-ci.org/vegansk/xmltools) 4 | 5 | High level xml library for Nim. 6 | 7 | ## Examples ## 8 | 9 | ### Simple searches ### 10 | 11 | ```nim 12 | let xml = Node.fromStringE """ 13 | 14 | 15 | 1 16 | 17 | 18 | 2 19 | 20 | 21 | 3 22 | 23 | 24 | """ 25 | 26 | # Find all tags that's parent is 27 | let bTags = xml / "b" 28 | # Find all tags recursive starting from the root 29 | let cTags = xml // "c" 30 | ``` 31 | 32 | ### Namespaces ### 33 | 34 | ```nim 35 | let xml = Node.fromStringE """ 36 | 38 | 39 | 40 | 41 | 1 42 | 2 43 | 44 | 45 | 46 | """ 47 | 48 | # Get namespaces declared in the root tag 49 | let nss = xml.namespaces 50 | # Check namespace presence by it's URL 51 | if nss.get("http://acme.com/api/v2").isDefined: 52 | # Get the namespace by it's URL 53 | let apiNs = nss.get("http://acme.com/api/v2").get 54 | # Get the value of tag using qualified name 55 | let sessionId = (xml // apiNs $: "session_id").text 56 | # Get the value of tag ignoring namespaces 57 | let issuerId = (xml // "*:issuer_id").text 58 | ``` 59 | 60 | ### Get all of the error messages in the SOAP response as multiline string ### 61 | 62 | ```nim 63 | let xml = Node.fromStringE """ 64 | 65 | 66 | 67 | 68 | SOAP-ENV:Client 69 | Validation error 70 | 71 | Schema validation error 72 | 73 | cvc-datatype-valid.1.2.1: 'ISSUER_ID_T' is not a valid value for 'integer'. 74 | 75 | 76 | cvc-type.3.1.3: The value 'ISSUER_ID_T' of element 'v2:issuer_id' is not valid. 77 | 78 | 79 | 80 | 81 | 82 | """ 83 | 84 | let msgs = (xml // "*:Fault") 85 | .flatMap((e: Node) => e // "*:description" ++ e // "*:ValidationError") 86 | .map((n: Node) => n.text) 87 | .foldLeft("", (s, v: string) => s & (if s == "": "" else: "\L") & v) 88 | ``` 89 | 90 | ### Xml to object parsing ### 91 | 92 | ```nim 93 | let xml = Node.fromStringE """ 94 | 95 | 100 96 | Hello, world! 97 | 98 | """ 99 | type Data = tuple[ 100 | id: int, 101 | str: string, 102 | optStr: Option[string] 103 | ] 104 | let o: EitherS[Data] = tryS do -> auto: 105 | ((xml /! "id").asInt, (xml /! "str").asStr, (xml / "opt_str").asStrO) 106 | ``` 107 | -------------------------------------------------------------------------------- /config.nims: -------------------------------------------------------------------------------- 1 | srcdir = "src" 2 | 3 | proc buildBase(debug: bool, bin: string, src: string) = 4 | switch("out", (thisDir() & "/" & bin).toExe) 5 | --nimcache: build 6 | if not debug: 7 | --forceBuild 8 | --define: release 9 | --opt: size 10 | else: 11 | --define: debug 12 | --debuginfo 13 | --debugger: native 14 | --linedir: on 15 | --stacktrace: on 16 | --linetrace: on 17 | --verbosity: 1 18 | 19 | --NimblePath: src 20 | --NimblePath: srcdir 21 | 22 | setCommand "c", src 23 | 24 | proc test(name: string) = 25 | if not dirExists "bin": 26 | mkDir "bin" 27 | --run 28 | buildBase true, "bin/test_" & name, "tests/test_" & name 29 | 30 | task test, "Run all tests": 31 | test "all" 32 | -------------------------------------------------------------------------------- /src/xmltools.nim: -------------------------------------------------------------------------------- 1 | import xmltree, 2 | xmlparser, 3 | streams, 4 | strutils, 5 | fp/either, 6 | fp/list, 7 | fp/option, 8 | fp/map, 9 | strtabs, 10 | sequtils, 11 | re, 12 | sugar, 13 | boost/parsers 14 | 15 | type 16 | Node* = distinct XmlNode 17 | NodeList* = List[Node] 18 | QNameImpl = tuple[ns: string, name: string] 19 | QName* = distinct QNameImpl 20 | Attr = (string, string) 21 | AttrValue* = Option[string] 22 | Attrs* = Map[string, string] 23 | Namespaces* = Map[string, string] 24 | NodeNotFoundError* = object of KeyError 25 | # We need this wrapper to generate normal error messages, and 26 | # module `re` doesn't have function `$` for Regex type 27 | XRegex* = ref object 28 | re: Regex 29 | p: string 30 | 31 | #################################################################################################### 32 | # Qualified name 33 | 34 | proc `$:`*(ns: string, name: string): QName = (ns: ns, name: name).QName 35 | 36 | converter toQName*(name: string): QName = 37 | let s = name.split(":") 38 | if s.len == 1: 39 | "" $: name 40 | else: 41 | s[0] $: s[1] 42 | 43 | 44 | proc ns*(q: QName): string = q.QNameImpl.ns 45 | proc name*(q: QName): string = q.QNameImpl.name 46 | 47 | proc nsDecl*(ns, url: string): Attr = 48 | ("xmlns" & (if ns == "": "" else: ":" & ns), url) 49 | 50 | proc `==`*(q1, q2: QName): bool {.borrow.} 51 | 52 | proc `$`*(qname: QName): string = 53 | if qname.ns == "": 54 | qname.name 55 | else: 56 | qname.ns & ":" & qname.name 57 | 58 | proc fromString(q: typedesc[QName], s: string): QName = 59 | let lst = s.split(":") 60 | if lst.len >= 2: 61 | lst[0] $: lst[1] 62 | else: 63 | s.toQName 64 | 65 | #################################################################################################### 66 | # Misc 67 | 68 | proc r*(s: string): XRegex = 69 | new(result) 70 | result.re = re(s) 71 | result.p = s 72 | 73 | proc `$`(r: XRegex): string = r.p 74 | 75 | proc name*(n: Node): QName = 76 | if n.XmlNode.kind != xnElement: 77 | QName.fromString("") 78 | else: 79 | QName.fromString(n.XmlNode.tag) 80 | 81 | proc nodeNotFoundMsg(n: string, name: string, deepSearch: bool): string = 82 | "Node $# doesn't have $# as it's $#" % [$n.name, name, if deepSearch: "descendant" else: "child"] 83 | 84 | proc toMap(s: StringTableRef): Map[string,string] = 85 | result = newMap[string,string]() 86 | for k,v in s: 87 | result = result + (k,v) 88 | 89 | #################################################################################################### 90 | # Node 91 | 92 | proc `$`*(n: Node): string = n.XmlNode.`$` 93 | 94 | proc fromStringE*(n = Node, s: string): Node = 95 | s.newStringStream.parseXml.Node 96 | 97 | proc fromString*(n: typedesc[Node], s: string): EitherS[Node] = 98 | tryS(() => Node.fromStringE(s)) 99 | 100 | proc text*(n: Node): string = n.XmlNode.innerText 101 | 102 | proc child*(n: Node, qname: QName): Option[Node] = n.XmlNode.child($qname).some.notNil.map((v: XmlNode) => v.Node) 103 | 104 | proc attr*(n: Node, qname: QName): AttrValue = 105 | if n.XmlNode.kind == xnElement and n.XmlNode.attrsLen > 0: 106 | let name = $qname 107 | if n.XmlNode.attrs.hasKey(name): n.XmlNode.attrs[name].some.AttrValue else: string.none.AttrValue 108 | else: 109 | string.none.AttrValue 110 | 111 | proc `/`*(n: Node, regex: XRegex): NodeList = 112 | result = Nil[Node]() 113 | if n.XmlNode.kind == xnElement: 114 | for ch in n.XmlNode: 115 | if ch.kind == xnElement and ch.tag.match(regex.re): 116 | result = Cons(ch.Node, result) 117 | result = result.reverse 118 | 119 | proc `/`*(n: Node, qname: QName): NodeList = 120 | if n.XmlNode.kind != xnElement: 121 | result = Nil[Node]() 122 | elif qname.ns == "*": 123 | result = n / r("^(.+:)?" & qname.name & "$") 124 | else: 125 | result = Nil[Node]() 126 | let name = $qname 127 | for ch in n.XmlNode: 128 | if ch.kind == xnElement and ch.tag == name: 129 | result = Cons(ch.Node, result) 130 | result = result.reverse 131 | 132 | proc `//`*(n: Node, regex: XRegex): NodeList = 133 | if n.XmlNode.kind != xnElement: 134 | result = Nil[Node]() 135 | else: 136 | result = n / regex 137 | for ch in n.XmlNode: 138 | result = result ++ ch.Node // regex 139 | 140 | proc `//`*(n: Node, qname: QName): NodeList = 141 | if qname.ns == "*": 142 | result = n // r("^(.+:)?" & qname.name & "$") 143 | else: 144 | result = n.XmlNode.findAll($qname).asList.map((v: XmlNode) => v.Node) 145 | 146 | proc findAttrName(n: Node, regex: XRegex): Option[QName] = 147 | if n.XmlNode.kind == xnElement and n.XmlNode.attrsLen > 0: 148 | for k in n.XmlNode.attrs.keys: 149 | if k.match(regex.re): 150 | return QName.fromString(k).some 151 | QName.none 152 | 153 | proc `/@`*(n: Node, regex: XRegex): NodeList = 154 | result = Nil[Node]() 155 | if n.XmlNode.kind == xnElement: 156 | for ch in n.XmlNode: 157 | if ch.Node.findAttrName(regex).isDefined: 158 | result = Cons(ch.Node, result) 159 | result = result.reverse 160 | 161 | proc asStrO*(n: NodeList|Node|AttrValue): Option[string] 162 | 163 | proc `/@`*(n: Node, name: QName): NodeList = 164 | result = Nil[Node]() 165 | if n.XmlNode.kind != xnElement: 166 | return 167 | elif name.ns == "*": 168 | result = n /@ r("^(.+:)?" & name.name & "$") 169 | else: 170 | for ch in n.XmlNode: 171 | if ch.Node.attr(name).isDefined: 172 | result = Cons(ch.Node, result) 173 | result = result.reverse 174 | 175 | proc `//@`*(n: Node, regex: XRegex): NodeList = 176 | if n.XmlNode.kind != xnElement: 177 | result = Nil[Node]() 178 | else: 179 | result = n /@ regex 180 | for ch in n.XmlNode: 181 | result = result ++ ch.Node //@ regex 182 | 183 | proc `//@`*(n: Node, name: QName): NodeList = 184 | result = Nil[Node]() 185 | if n.XmlNode.kind != xnElement: 186 | return 187 | elif name.ns == "*": 188 | result = n //@ r("^(.+:)?" & name.name & "$") 189 | else: 190 | result = n /@ name 191 | for ch in n.XmlNode: 192 | result = result ++ ch.Node //@ name 193 | 194 | proc namespaces*(n:Node): Namespaces = 195 | let p = n.XmlNode.attrs.toMap 196 | p.filter((i: (string, string)) => QName.fromString(i.key).ns == "xmlns") 197 | .map((i: (string, string)) => (i.value, QName.fromString(i.key).name)) 198 | 199 | #################################################################################################### 200 | # NodeList 201 | 202 | proc `$`*(lst: NodeList): string = 203 | lst.foldLeft("", (s: string, n: Node) => s & $n) 204 | 205 | proc `/`*(lst: NodeList, qname: QName): NodeList = 206 | lst.flatMap((n: Node) => n / qname) 207 | 208 | proc `//`*(lst: NodeList, qname: QName): NodeList = 209 | lst.flatMap((n: Node) => n // qname) 210 | 211 | proc text*(lst: NodeList): string = 212 | lst.foldLeft("", (s: string, n: Node) => s & n.text) 213 | 214 | #################################################################################################### 215 | # Data getters 216 | 217 | proc `/!`*(n: NodeList|Node, v: QName|XRegex): Node = 218 | let res = n / v 219 | if res.isEmpty: 220 | when n is Node: 221 | raise newException(NodeNotFoundError, nodeNotFoundMsg($n.name, $v, false)) 222 | else: 223 | raise newException(NodeNotFoundError, nodeNotFoundMsg("", $v, false)) 224 | res.head 225 | 226 | proc `//!`*(n: NodeList|Node, v: QName|XRegex): Node = 227 | let res = n // v 228 | if res.isEmpty: 229 | when n is Node: 230 | raise newException(NodeNotFoundError, nodeNotFoundMsg($n.name, $v, true)) 231 | else: 232 | raise newException(NodeNotFoundError, nodeNotFoundMsg("", $v, true)) 233 | res.head 234 | 235 | proc asStrO(n: NodeList|Node|AttrValue): Option[string] = 236 | when n is NodeList: 237 | n.headOption.map((n: Node) => n.text).notEmpty 238 | elif n is AttrValue: 239 | n 240 | else: 241 | n.text.some.notEmpty 242 | 243 | proc asStr*(n: NodeList|Node|AttrValue): string = 244 | n.asStrO.getOrElse("") 245 | 246 | proc asIntO*(n: NodeList|Node|AttrValue): Option[int] = 247 | n.asStrO.map((v: string) => v.strToInt) 248 | 249 | proc asInt*(n: Node|AttrValue): int = 250 | n.asStr.strToInt 251 | 252 | proc asInt64O*(n: NodeList|Node|AttrValue): Option[int64] = 253 | n.asStrO.map((v: string) => v.strToInt64) 254 | 255 | proc asInt64*(n: Node|AttrValue): int64 = 256 | n.asStr.strToInt64 257 | 258 | proc asUIntO*(n: NodeList|Node|AttrValue): Option[uint] = 259 | n.asStrO.map((v: string) => v.strToUInt) 260 | 261 | proc asUInt*(n: Node|AttrValue): uint = 262 | n.asStr.strToUInt 263 | 264 | proc asUInt64O*(n: NodeList|Node|AttrValue): Option[uint64] = 265 | n.asStrO.map((v: string) => v.strToUInt64) 266 | 267 | proc asUInt64*(n: Node|AttrValue): uint64 = 268 | n.asStr.strToUInt64 269 | 270 | ##################################################################################################### 271 | # XmlBuilder 272 | 273 | type NodeBuilder* = () -> Node 274 | 275 | proc run*(b: NodeBuilder): Node = b() 276 | 277 | proc endn*(): List[NodeBuilder] = Nil[NodeBuilder]() 278 | 279 | proc attrs*(attrs: varargs[Attr]): Attrs = 280 | asMap(attrs) 281 | 282 | proc el*(qname: Qname, attrs: Attrs, children: List[NodeBuilder]): NodeBuilder = 283 | result = proc(): Node = 284 | var res = ($qname).newElement 285 | res.attrs = newStringTable() 286 | attrs.forEach((v: Attr) => (res.attrs[v.key] = v.value)) 287 | children.forEach((nb: NodeBuilder) => res.add(nb().XmlNode)) 288 | result = res.Node 289 | proc el*(qname: Qname, attrs: Attrs, children: varargs[NodeBuilder]): NodeBuilder = el(qname, attrs, asList(children)) 290 | proc el*(qname: Qname, children: List[NodeBuilder]): NodeBuilder = el(qname, attrs(), children) 291 | proc el*(qname: Qname, children: varargs[NodeBuilder]): NodeBuilder = el(qname, attrs(), asList(children)) 292 | 293 | proc textEl*(data: string): NodeBuilder = 294 | () => newText(data).Node 295 | -------------------------------------------------------------------------------- /tests/test_all.nim: -------------------------------------------------------------------------------- 1 | import unittest, 2 | fp/either, 3 | fp/option, 4 | fp/map, 5 | xmltools, 6 | re, 7 | future 8 | import fp/list except `$` 9 | 10 | suite "xmltools": 11 | test "Conversion": 12 | let s = "1234" 13 | let x = Node.fromString(s) 14 | require: x.isRight 15 | check: $(x.get) == s 16 | let bx = Node.fromString("112233""" 27 | check: $(xml / "b") == "123" 28 | 29 | let xmlWithNs = Node.fromStringE """ 30 | 31 | 11 32 | 22 33 | 33 34 | 35 | """ 36 | check: $(xmlWithNs / "ns" $: "b") == "123" 37 | 38 | let xmlTree = Node.fromStringE """123 """ 39 | let ns = "" 40 | check: $(xmlTree / ns $: "b" / ns $: "c") == "123" 41 | check: $(xmlTree // ns $: "c") == "123" 42 | 43 | test "Accessors": 44 | let xml = Node.fromStringE """112233""" 45 | check: xml.name == "a" 46 | check: (xml // "b").text == "123" 47 | check: xml.child("b").flatMap((v: Node) => v.attr("id")).getOrElse("") == "100" 48 | 49 | test "Namespaces": 50 | let xml = Node.fromStringE """ 51 | 53 | 54 | 55 | 56 | 1 57 | 2 58 | 59 | 60 | 61 | """ 62 | let nss = xml.namespaces 63 | require: nss.get("http://acme.com/api/v2").isDefined 64 | let apiNs = nss.get("http://acme.com/api/v2").get 65 | check: (xml // apiNs $: "session_id").text == "1" 66 | check: (xml // apiNs $: "issuer_id").text == "2" 67 | 68 | test "Advanced searches": 69 | let xml = Node.fromStringE """ 70 | 71 | 72 | 73 | 74 | SOAP-ENV:Client 75 | Validation error 76 | 77 | Lily was here! 78 | cvc-datatype-valid.1.2.1: 'ISSUER_ID_T' is not a valid value for 'integer'. 79 | cvc-type.3.1.3: The value 'ISSUER_ID_T' of element 'v2:issuer_id' is not valid. 80 | 81 | 82 | 83 | 84 | """ 85 | check: (xml // "*" $: "Fault").length == 1 86 | check: (xml // "*" $: "ValidationError").length == 2 87 | check: (xml // "*" $: "ValidationError").text == (xml // "*" $: "Fault" // "*" $: "ValidationError").text 88 | check: (xml /@ "xmlns" $: "spring-ws").length == 0 89 | check: (xml //@ "xmlns" $: "spring-ws").length == 2 90 | check: (xml /@ "*" $: "spring-ws").length == 0 91 | check: (xml //@ "*" $: "spring-ws").length == 2 92 | check: (xml /@ "xmlns:spring-ws").length == 0 93 | check: (xml //@ "xmlns:spring-ws").length == 2 94 | 95 | echo: (xml // "*:Fault") 96 | .flatMap((e: Node) => e // "*:description" ++ e // "*:ValidationError") 97 | .map((n: Node) => n.text) 98 | .foldLeft("", (s, v: string) => s & (if s == "": "" else: "\L") & v) 99 | 100 | test "Strong searches and data getters": 101 | let xml = Node.fromStringE """ 102 | 103 | test 104 | 100500 105 | 106 | """ 107 | expect(NodeNotFoundError): discard xml /! "test" 108 | expect(NodeNotFoundError): discard xml //! "test" 109 | check: (xml /! r"b" /! "v").asStrO == "test".some 110 | check: (xml //! "v").asStrO == "test".some # Only first v is used 111 | check: (xml //! "v").asStr == "test" # Only first v is used 112 | expect(ValueError): discard (xml //! "v").asInt # Only first v is used 113 | check: (xml // "c" /! "v").asIntO == 100500.some 114 | check: (xml // "c" /! "v").asInt == 100500 115 | 116 | test "Parse to object": 117 | let xml = Node.fromStringE """ 118 | 119 | Hello, world! 120 | 121 | """ 122 | type Data = tuple[ 123 | id: int, 124 | str: string, 125 | optStr: Option[string] 126 | ] 127 | let o: EitherS[Data] = tryS do -> auto: 128 | Data((xml.attr("id").asInt, (xml /! "str").asStr, (xml / "opt_str").asStrO)) 129 | check: o == (100, "Hello, world!", string.none).rightS 130 | 131 | test "XML builder": 132 | check: $(el"test".run) == "" 133 | check: $(el("ns" $: "test").run) == "" 134 | let xml = el("test", el("a", el("ns" $: "child")), el("b"), el("c")) 135 | echo xml() 136 | let xmla = el("test", attrs(("a", "b")), el("a")) 137 | echo xmla() 138 | check: $(el("test", textEl("data")).run) == "data" 139 | -------------------------------------------------------------------------------- /xmltools.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.1.6" 4 | author = "Anatoly Galiulin " 5 | description = "High level xml library for Nim" 6 | license = "MIT" 7 | 8 | srcDir = "src" 9 | 10 | # Dependencies 11 | 12 | requires "nim >= 0.19.0", "nimfp >= 0.4.4", "nimboost >= 0.5.5" 13 | --------------------------------------------------------------------------------