I have written a script library to hold a bunch of functions that GET data from an inventory management system API.
All has been working for years until a curveball recently threw my way.
There was a URL parameter value that had a space in it.
Seems like such things are not supported and need to be URL encoded.
ie: “My Warehouse” must be encoded to “My+Warehouse” or “My%20Warehouse” to work in the API URL.
I’ve been trying to use the encoder recommended elsewhere in the forums:
from java.net import URLEncoder
from java.nio.charset import StandardCharsets
Apparently only the VALUE part of the parameter key/value pair should be encoded.
So something like this:
params={'pageSize': 200, 'WarehouseCode': 'G1.2 Prod'}
Is converted to a URL string like this, which will be appended to the end of the BASE URL that is sent to the API call.
result=pageSize=200&WarehouseCode=G1.2+Prod
However now I’m getting authorization errors (403) because (I presume) the ‘api-auth-signature’ generated does not match.
I think i’m going in circles a bit trying to figure out what needs encoding and where.
Does anyone have any good examples of:
-
Generating the URL parameter string from a dictionary.
-
Generating the appropriate header signature required
If you use system.net.httpClient it'll do all this for you.
1 Like
Huh looks like I had some old testing functions that were trying to do just that.
Looks like I can get it something to work without putting the parameters into the URL myself (as below), but I believe the URL Headers are still giving me problems, because I need to generate that myself to create the api-auth-signature. And the missmatch between the original WarehouseCode value and the encoded one causes signature missmatch right?
def generateSignature(urlQueryParams):
""" This function returns an encoded signature based on url query parameters
and the unique API signature key
Args:
urlQueryParams (str): string of url parameters to encode into signature
"""
import hashlib
import base64
import hmac
scriptName = "shared.api.generateSignature"
message = urlQueryParams.encode('utf-8')
apiScriptLogger.debug("%s - urlQueryParams=%s, utf-8 encoded message=%s" % (scriptName, urlQueryParams, message))
secret = API_KEY.encode('utf-8')
hmacDigest = hmac.new(secret,message, digestmod=hashlib.sha256).digest()
return base64.b64encode(hmacDigest)
#end def
def generateHeaders(urlQueryParams):
return {
"Accept": "application/json",
"api-auth-id": API_ID,
"api-auth-signature": shared.api.generateSignature(urlQueryParams),
"Content-Type": "application/json",
"client-type": "Ignition"
}
#end def
url = BASE_URL + endpoint # NO url params - will be passed into getAsync and handled for us
# Custom function to take parameters and generate headers with signature
urlHeaders = shared.api.generateHeaders(shared.api.urlParamEncode(paramsDict))
try:
promise = httpClient.getAsync(url=url, headers=urlHeaders, params=paramsDict, timeout = 30000) # Timeout = read timeout.
LOGGER.info("%s - waiting for Async promise to complete" % (scriptName))
response = promise.get()
if response.isGood():
shared.log.debug("%s - GET request to: '%s' returned StatusCode=%0d" % (scriptName, url, response.getStatusCode()))
else:
shared.log.error("%s - GET request to: '%s' returned StatusCode=%0d, response: %s" % (scriptName, url, response.getStatusCode(), response.getJson()))
# end if
except:
response = None
finally:
endTime = shared.util.getGatewayDateTime()
secondsElapsed = system.date.secondsBetween(startTime, endTime)
###LOGGER.info("%s - statusCode=%s, duration=%0ds" % (scriptName, response.getStatusCode(), secondsElapsed))
# Record request attempt in SQL for future reference
recordHttpAttempt('GET', endpoint, paramsDict, url, response, secondsElapsed)
return response
# end try
Can you share any documentation for the API you're talking to, and/or what a valid request looks like and its associated signature?
Unleashed API:
The method signature must be generated by taking the query string, and creating a HMAC-SHA256 signature using your API key as the secret key.
Only the query parameters portion of the URL is used in calculating the signature, e.g. for the request / Customers?customerCode=ACME use the string customerCode=ACME when generating the signature. Do not include the endpoint name in the method signature.
I am generating the signature with just the url query parameters, but perhaps my conversion of the pyDict parameters to the URL param string isn’t quite right when spaces get involved.
I had played around with using the encoder java method as per my first post, but then found I didn’t need to do so when passing parameters into the beefier httpClient method.
A few minutes with Gemini gave me this result, which looks like it should work:
import java.net.URLEncoder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64
import java.lang.String
def generate_unleashed_signature(params, api_key):
"""
Generates an HMAC-SHA256 signature for the Unleashed Software API.
Args:
params (dict): A dictionary of unescaped string parameters.
api_key (str): The user's private API key.
Returns:
str: The Base64 encoded HMAC-SHA256 signature.
"""
# 1. Construct the Query String
# The API requires the signature to be based on the query string.
# We sort keys to ensure a deterministic order (common practice),
# though you must ensure the actual URL uses this same order.
sorted_keys = sorted(params.keys())
encoded_parts = []
for key in sorted_keys:
# Convert to string in case of non-string input
k_str = str(key)
v_str = str(params[key])
# Use java.net.URLEncoder to encode keys and values
# Standard UTF-8 encoding
enc_k = java.net.URLEncoder.encode(k_str, "UTF-8")
enc_v = java.net.URLEncoder.encode(v_str, "UTF-8")
encoded_parts.append("{}={}".format(enc_k, enc_v))
# Join with '&' to form the query string (e.g., "id=123&value=test")
# If params is empty, this results in an empty string "", which is handled correctly per docs.
query_string = "&".join(encoded_parts)
# 2. Generate HMAC-SHA256 Signature
algorithm = "HmacSHA256"
# Create the SecretKeySpec using the API Key
# We use java.lang.String.getBytes() to ensure we pass a Java byte[]
key_bytes = java.lang.String(api_key).getBytes("UTF-8")
secret_key_spec = SecretKeySpec(key_bytes, algorithm)
# Initialize the Mac instance
mac = Mac.getInstance(algorithm)
mac.init(secret_key_spec)
# Compute the hash of the query string
data_bytes = java.lang.String(query_string).getBytes("UTF-8")
signature_bytes = mac.doFinal(data_bytes)
# 3. Base64 Encode
# Using java.util.Base64 (Available in Java 8+, commonly used with Jython 2.7)
encoder = java.util.Base64.getEncoder()
signature_b64 = encoder.encodeToString(signature_bytes)
return signature_b64
An interesting callout that I didn't think of - this explicitly sorts the input parameters, which is something that the automatic URL escaping in system.net.httpClient does not do. You should pass an OrderedDict in to httpClient to make sure the automatic URL construction uses the same ordering as your key generation routine.