Skip to content

Commit 6562f26

Browse files
feat(apiclient): automatic IDN conversion of API command parameters to punycode
1 parent 8e98f04 commit 6562f26

File tree

3 files changed

+105
-26
lines changed

3 files changed

+105
-26
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ This module is a connector library for the insanely fast HEXONET Backend API. Fo
1818
* [Release Notes](https://github.com/hexonet/python-sdk/releases)
1919
* [Development Guide](https://hexonet-python-sdk.readthedocs.io/en/latest/developmentguide.html)
2020

21+
## Features
22+
23+
* Automatic IDN Domain name conversion to punycode (our API accepts only punycode format in commands)
24+
* Allow nested associative arrays in API commands to improve for bulk parameters
25+
* Connecting and communication with our API
26+
* Several ways to access and deal with response data
27+
* Getting the command again returned together with the response
28+
* sessionless communication
29+
* session-based communication
30+
* possibility to save API session identifier in PHP session
31+
2132
## How to use this module in your project
2233

2334
All you need to know, can be found [here](https://hexonet-python-sdk.readthedocs.io/en/latest/#usage-guide).

hexonet/apiconnector/apiclient.py

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,7 @@ def getPOSTData(self, cmd):
5858
if not isinstance(cmd, str):
5959
for key in sorted(cmd.keys()):
6060
if (cmd[key] is not None):
61-
if isinstance(cmd[key], list):
62-
i = 0
63-
while i < len(cmd[key]):
64-
tmp += ("{0}{1}={2}\n").format(key, i, re.sub('[\r\n]', '', str(cmd[key][i])))
65-
i += 1
66-
else:
67-
tmp += ("{0}={1}\n").format(key, re.sub('[\r\n]', '', str(cmd[key])))
61+
tmp += ("{0}={1}\n").format(key, re.sub('[\r\n]', '', str(cmd[key])))
6862
return ("{0}{1}={2}").format(data, quote('s_command'), quote(re.sub('\n$', '', tmp)))
6963

7064
def getSession(self):
@@ -213,7 +207,13 @@ def request(self, cmd):
213207
"""
214208
Perform API request using the given command
215209
"""
216-
data = self.getPOSTData(cmd).encode('UTF-8')
210+
# flatten nested api command bulk parameters
211+
newcmd = self.__flattenCommand(cmd)
212+
# auto convert umlaut names to punycode
213+
newcmd = self.__autoIDNConvert(newcmd)
214+
215+
# request command to API
216+
data = self.getPOSTData(newcmd).encode('UTF-8')
217217
# TODO: 300s (to be sure to get an API response)
218218
try:
219219
req = Request(self.__socketURL, data, {
@@ -226,19 +226,19 @@ def request(self, cmd):
226226
body = rtm.getTemplate("httperror").getPlain()
227227
if (self.__debugMode):
228228
print((self.__socketURL, data, "HTTP communication failed", body, '\n', '\n'))
229-
return Response(body, cmd)
229+
return Response(body, newcmd)
230230

231231
def requestNextResponsePage(self, rr):
232232
"""
233233
Request the next page of list entries for the current list query
234234
Useful for tables
235235
"""
236-
mycmd = self.__toUpperCaseKeys(rr.getCommand())
236+
mycmd = rr.getCommand()
237237
if ("LAST" in mycmd):
238238
raise Exception("Parameter LAST in use. Please remove it to avoid issues in requestNextPage.")
239239
first = 0
240240
if ("FIRST" in mycmd):
241-
first = mycmd["FIRST"]
241+
first = int(mycmd["FIRST"])
242242
total = rr.getRecordsTotalCount()
243243
limit = rr.getRecordsLimitation()
244244
first += limit
@@ -293,11 +293,54 @@ def useLIVESystem(self):
293293
self.__socketConfig.setSystemEntity("54cd")
294294
return self
295295

296-
def __toUpperCaseKeys(self, cmd):
296+
def __flattenCommand(self, cmd):
297297
"""
298-
Translate all command parameter names to uppercase
298+
Flatten API command to handle it easier later on (nested array for bulk params)
299299
"""
300300
newcmd = {}
301-
for k in list(cmd.keys()):
302-
newcmd[k.upper()] = cmd[k]
301+
for key in list(cmd.keys()):
302+
newKey = key.upper()
303+
val = cmd[key]
304+
if val is None:
305+
continue
306+
if isinstance(val, list):
307+
i = 0
308+
while i < len(val):
309+
newcmd[newKey + str(i)] = re.sub(r'[\r\n]', '', str(val[i]))
310+
i += 1
311+
else:
312+
newcmd[newKey] = re.sub(r'[\r\n]', '', str(val))
303313
return newcmd
314+
315+
def __autoIDNConvert(self, cmd):
316+
"""
317+
Auto convert API command parameters to punycode, if necessary.
318+
"""
319+
# don't convert for convertidn command to avoid endless loop
320+
# and ignore commands in string format(even deprecated)
321+
if isinstance(cmd, str) or re.match(r'^CONVERTIDN$', cmd["COMMAND"], re.IGNORECASE):
322+
return cmd
323+
324+
toconvert = []
325+
keys = []
326+
for key in cmd:
327+
if re.match(r'^(DOMAIN|NAMESERVER|DNSZONE)([0-9]*)$', key, re.IGNORECASE):
328+
keys.append(key)
329+
if not keys.count:
330+
return cmd
331+
idxs = []
332+
for key in keys:
333+
if not re.match(r'^[a-z0-9.-]+$', cmd[key], re.IGNORECASE):
334+
toconvert.append(cmd[key])
335+
idxs.append(key)
336+
337+
r = self.request({
338+
"COMMAND": "ConvertIDN",
339+
"DOMAIN": toconvert
340+
})
341+
if r.isSuccess():
342+
col = r.getColumn("ACE")
343+
if col is not None:
344+
for idx, pc in enumerate(col.getData()):
345+
cmd[idxs[idx]] = pc
346+
return cmd

tests/test_apiclient.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,6 @@ def test_apiclientmethods():
8888
})
8989
assert enc == validate
9090

91-
# support bulk parameters also as nested array
92-
validate = 's_entity=54cd&s_command=COMMAND%3DQueryDomainOptions%0ADOMAIN0%3Dexample1.com%0ADOMAIN1%3Dexample2.com'
93-
enc = cl.getPOSTData({
94-
"COMMAND": 'QueryDomainOptions',
95-
"DOMAIN": [
96-
'example1.com',
97-
'example2.com'
98-
]
99-
})
100-
assert enc == validate
101-
10291
# #.enableDebugMode()
10392
cl.enableDebugMode()
10493
cl.disableDebugMode()
@@ -264,6 +253,42 @@ def test_apiclientmethods():
264253
assert rec is not None
265254
assert rec.getDataByKey('SESSION') is not None
266255

256+
# support bulk parameters also as nested array (flattenCommand)
257+
r = cl.request({
258+
'COMMAND': 'CheckDomains',
259+
'DOMAIN': ['example.com', 'example.net']
260+
})
261+
assert isinstance(r, R) is True
262+
assert r.isSuccess() is True
263+
assert r.getCode() is 200
264+
assert r.getDescription() == "Command completed successfully"
265+
cmd = r.getCommand()
266+
keys = cmd.keys()
267+
assert ("DOMAIN0" in keys) is True
268+
assert ("DOMAIN1" in keys) is True
269+
assert ("DOMAIN" in keys) is False
270+
assert cmd["DOMAIN0"] == "example.com"
271+
assert cmd["DOMAIN1"] == "example.net"
272+
273+
# support autoIDNConvert
274+
r = cl.request({
275+
'COMMAND': 'CheckDomains',
276+
'DOMAIN': ['example.com', 'dömäin.example', 'example.net']
277+
})
278+
assert isinstance(r, R) is True
279+
assert r.isSuccess() is True
280+
assert r.getCode() is 200
281+
assert r.getDescription() == "Command completed successfully"
282+
cmd = r.getCommand()
283+
keys = cmd.keys()
284+
assert ("DOMAIN0" in keys) is True
285+
assert ("DOMAIN1" in keys) is True
286+
assert ("DOMAIN2" in keys) is True
287+
assert ("DOMAIN" in keys) is False
288+
assert cmd["DOMAIN0"] == "example.com"
289+
assert cmd["DOMAIN1"] == "xn--dmin-moa0i.example"
290+
assert cmd["DOMAIN2"] == "example.net"
291+
267292
# [login succeeded; role used]
268293
cl.useOTESystem()
269294
cl.setRoleCredentials('test.user', 'testrole', 'test.passw0rd')

0 commit comments

Comments
 (0)