VBCommons/utility/API_JSON.cls

619 lines
19 KiB
OpenEdge ABL
Raw Normal View History

2024-06-07 20:46:40 +03:00
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "API_JSON"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
' ======== JSON converter ========
' Shared module version: 20220813
' Tested in: TestCommons
' Depends on:
' Required reference: Scripting
' This code is based on rethinking VBA-JSON, https://github.com/VBA-tools/VBA-JSON
Option Explicit
Private Const ERR_PARSE_FAIL = vbObject + 2049
' JSON states esaping '/' as optional.
' Use this flag to escape them as '\/'
Public escapeSlash_ As Boolean
' JSON doesnt require escaping of non-AscII chars.
' Use this flag to enable \u codes escape mechanics
Public escapeUnicode_ As Boolean
Private multiline_ As Boolean
Private wsCharacter_ As String
Private wsCount_ As Long
Private json_ As String
Private inputLen_ As Long
Private position_ As Long
Private Sub Class_Initialize()
escapeSlash_ = False
escapeUnicode_ = False
Call DisableMultiline
End Sub
' {Integer|String} vWhitespace "Pretty" print json with given number of spaces per indentation (Integer) or given string
Public Function SetupMultiline(ByVal vWhitespace As Variant)
multiline_ = True
If VBA.VarType(vWhitespace) = VBA.vbString Then
wsCharacter_ = vWhitespace
wsCount_ = -1
Else
wsCharacter_ = vbNullString
wsCount_ = vWhitespace
End If
End Function
Public Function DisableMultiline()
multiline_ = False
wsCharacter_ = vbNullString
wsCount_ = -1
End Function
' Convert object (Dictionary/Collection/Array) to JSON
Public Function CreateJSON(ByRef vValue As Variant) As String
CreateJSON = Generator_Process(vValue)
End Function
' Convert JSON string to object (Dictionary/Collection)
Public Function Parse(sJson$) As Object
json_ = VBA.Replace(VBA.Replace(VBA.Replace(sJson, VBA.vbCr, ""), VBA.vbLf, ""), VBA.vbTab, "")
position_ = 1
inputLen_ = VBA.Len(json_)
Call Parser_SkipWS
Select Case Parser_ViewAny()
Case "{": Set Parse = Parser_Object()
Case "[": Set Parse = Parser_Array()
Case Else: Call Parser_RaiseError("Expected '{' or '['")
End Select
If position_ <= inputLen_ Then _
Call Parser_RaiseError("Some part of input is out of main object scope")
json_ = vbNullString
End Function
Public Function SaveToFile(sFile$, iData As Object) As Boolean
Const DO_OVERWRITE& = 2
SaveToFile = False
On Error GoTo EXIT_ERROR
Dim adoStream As Object: Set adoStream = CreateObject("ADODB.Stream")
adoStream.Charset = "utf-8"
Call adoStream.Open
Call adoStream.WriteText(CreateJSON(iData))
Call adoStream.SaveToFile(sFile, DO_OVERWRITE)
SaveToFile = True
EXIT_ERROR:
On Error GoTo 0
Call adoStream.Close
End Function
Public Function LoadFromFile(sFile$) As Object
Dim sJson$: sJson = ReadFile(sFile)
If sJson = vbNullString Then _
Exit Function
Set LoadFromFile = Parse(sJson)
End Function
' =============================================
Private Function ReadFile(sFile$) As String
Dim adoStream As Object: Set adoStream = CreateObject("ADODB.Stream")
adoStream.Charset = "utf-8"
Call adoStream.Open
On Error GoTo ERROR_FILE
Call adoStream.LoadFromFile(sFile)
On Error GoTo 0
ReadFile = adoStream.ReadText
ERROR_FILE:
Call adoStream.Close
On Error GoTo 0
End Function
Private Function Generator_Process(ByRef vValue As Variant, Optional nIdent& = 0) As String
Select Case VBA.VarType(vValue)
Case VBA.vbNull: Generator_Process = "null"
Case VBA.vbBoolean: Generator_Process = IIf(vValue, "true", "false")
Case VBA.vbDate: Generator_Process = """" & CDbl(vValue) & """"
Case VBA.vbString
If IsLargeNumber(vValue) Then
Generator_Process = vValue ' Large number encoded as string
Else
Generator_Process = """" & EncodeStr(vValue) & """"
End If
Case VBA.vbArray To VBA.vbArray + VBA.vbByte: Generator_Process = Generator_Array(vValue, nIdent)
Case VBA.vbObject
If VBA.TypeName(vValue) = "Dictionary" Then
Generator_Process = Generator_Dictionary(vValue, nIdent)
ElseIf VBA.TypeName(vValue) = "Collection" Then
Generator_Process = Generator_Collection(vValue, nIdent)
Else ' TODO: call custom object function to generate JSON!
Call Err.Raise(1000, Description:="Unknown object type. Please use Dictionary or Collection")
End If
Case VBA.vbInteger, VBA.vbLong, VBA.vbSingle, VBA.vbDouble, VBA.vbCurrency, VBA.vbDecimal
Generator_Process = VBA.Replace(vValue, ",", ".") ' Number (use decimals for numbers)
Case Else ' vbEmpty, vbError, vbDataObject, vbByte, vbUserDefinedType
On Error Resume Next
Generator_Process = vValue
On Error GoTo 0
End Select
End Function
Private Function Generator_Array(ByRef vValue As Variant, nIdent&) As String
Dim sBuffer$
Dim nBufferPosition&
Dim nBufferLen&
Call BufferAppend(sBuffer, "[", nBufferPosition, nBufferLen)
Dim nSize1&: nSize1 = ArraySize(vValue, 1)
If nSize1 <= 0 Then _
GoTo PRINT_TO_STR
Dim nIndex1Start&: nIndex1Start = LBound(vValue, 1)
Dim nSize2&: nSize2 = ArraySize(vValue, 2)
Dim nIndex2Start&
If nSize2 > 0 Then _
nIndex2Start = LBound(vValue, 2)
Dim sIndentLevel1$: sIndentLevel1 = GetWS(nIdent + 1)
Dim sIndentLevel2$: sIndentLevel2 = GetWS(nIdent + 2)
Dim bIsFirst: bIsFirst = True
Dim json_Converted$
Dim nIndex1&
For nIndex1 = nIndex1Start To nIndex1Start + nSize1 - 1 Step 1
If Not bIsFirst Then
Call BufferAppend(sBuffer, ",", nBufferPosition, nBufferLen)
Else
bIsFirst = False
End If
If nSize2 <= 0 Then ' 1D Array
json_Converted = Generator_Nullable(vValue(nIndex1), nIdent + 1)
If multiline_ Then _
json_Converted = vbNewLine & sIndentLevel1 & json_Converted
Call BufferAppend(sBuffer, json_Converted, nBufferPosition, nBufferLen)
Else ' 2D Array
If multiline_ Then _
Call BufferAppend(sBuffer, vbNewLine, nBufferPosition, nBufferLen)
Call BufferAppend(sBuffer, sIndentLevel1 & "[", nBufferPosition, nBufferLen)
Dim nIndex2&
Dim bIsFirst2 As Boolean: bIsFirst2 = True
For nIndex2 = nIndex2Start To nIndex2Start + nSize2 - 1 Step 1
If Not bIsFirst2 Then
Call BufferAppend(sBuffer, ",", nBufferPosition, nBufferLen)
Else
bIsFirst2 = False
End If
json_Converted = Generator_Nullable(vValue(nIndex1, nIndex2), nIdent + 2)
If multiline_ Then _
json_Converted = vbNewLine & sIndentLevel2 & json_Converted
Call BufferAppend(sBuffer, json_Converted, nBufferPosition, nBufferLen)
Next nIndex2
If multiline_ Then _
Call BufferAppend(sBuffer, vbNewLine, nBufferPosition, nBufferLen)
Call BufferAppend(sBuffer, sIndentLevel1 & "]", nBufferPosition, nBufferLen)
End If
Next nIndex1
PRINT_TO_STR:
On Error GoTo 0
If multiline_ Then
Call BufferAppend(sBuffer, vbNewLine & GetWS(nIdent) & "]", nBufferPosition, nBufferLen)
Else
Call BufferAppend(sBuffer, "]", nBufferPosition, nBufferLen)
End If
Generator_Array = BufferToString(sBuffer, nBufferPosition)
End Function
Private Function Generator_Collection(ByRef vValue As Variant, nIdent&) As String
Dim sBuffer$
Dim nBufferPosition&
Dim nBufferLen&
Call BufferAppend(sBuffer, "[", nBufferPosition, nBufferLen)
Dim bIsFirst As Boolean: bIsFirst = True
Dim anElement As Variant
For Each anElement In vValue
If Not bIsFirst Then
Call BufferAppend(sBuffer, ",", nBufferPosition, nBufferLen)
Else
bIsFirst = False
End If
Dim json_Converted$: json_Converted = Generator_Nullable(anElement, nIdent + 1)
If multiline_ Then _
json_Converted = vbNewLine & GetWS(nIdent + 1) & json_Converted
Call BufferAppend(sBuffer, json_Converted, nBufferPosition, nBufferLen)
Next anElement
If multiline_ Then
Call BufferAppend(sBuffer, vbNewLine & GetWS(nIdent) & "]", nBufferPosition, nBufferLen)
Else
Call BufferAppend(sBuffer, "]", nBufferPosition, nBufferLen)
End If
Generator_Collection = BufferToString(sBuffer, nBufferPosition)
End Function
Private Function Generator_Dictionary(ByRef vValue As Variant, nIdent&) As String
Dim sBuffer$
Dim nBufferPosition&
Dim nBufferLen&
Call BufferAppend(sBuffer, "{", nBufferPosition, nBufferLen)
Dim bIsFirst As Boolean: bIsFirst = True
Dim aKey As Variant
For Each aKey In vValue.Keys
Dim json_Converted$: json_Converted = Generator_Process(vValue(aKey), nIdent + 1)
If json_Converted = vbNullString Then _
If IsUndefined(vValue(aKey)) Then _
GoTo NEXT_KEY ' For Objects, undefined (Empty/Nothing) is not added to object
If Not bIsFirst Then
Call BufferAppend(sBuffer, ",", nBufferPosition, nBufferLen)
Else
bIsFirst = False
End If
If multiline_ Then
json_Converted = vbNewLine & GetWS(nIdent + 1) & """" & aKey & """: " & json_Converted
Else
json_Converted = """" & aKey & """:" & json_Converted
End If
Call BufferAppend(sBuffer, json_Converted, nBufferPosition, nBufferLen)
NEXT_KEY:
Next aKey
If multiline_ Then
Call BufferAppend(sBuffer, vbNewLine & GetWS(nIdent) & "}", nBufferPosition, nBufferLen)
Else
Call BufferAppend(sBuffer, "}", nBufferPosition, nBufferLen)
End If
Generator_Dictionary = BufferToString(sBuffer, nBufferPosition)
End Function
Private Function Generator_Nullable(ByVal vValue As Variant, nIdent&)
Generator_Nullable = Generator_Process(vValue, nIdent)
If Generator_Nullable = vbNullString Then _
If IsUndefined(vValue) Then _
Generator_Nullable = "null"
End Function
Private Function Parser_Object() As Scripting.Dictionary
If Parser_ConsumeChar() <> "{" Then _
Call Parser_RaiseError("Expected '{'")
Set Parser_Object = New Scripting.Dictionary
Do
Call Parser_SkipWS
Dim sNextChar$: sNextChar = Parser_ViewChar()
If sNextChar = "}" Then
Call Parser_Increment
Exit Function
ElseIf sNextChar = "," Then
Call Parser_Increment
Call Parser_SkipWS
End If
Dim sKey$: sKey = Parser_Key()
If Parser_ConsumeChar(1) <> ":" Then _
Call Parser_RaiseError("Expected ':'")
sNextChar = Parser_ViewChar()
If sNextChar = "[" Or sNextChar = "{" Then
Set Parser_Object.Item(sKey) = Parser_Value()
Else
Parser_Object.Item(sKey) = Parser_Value()
End If
Loop
End Function
Private Function Parser_Array() As Collection
If Parser_ConsumeChar() <> "[" Then _
Call Parser_RaiseError("Expected '['")
Set Parser_Array = New Collection
Do
Call Parser_SkipWS
Dim sChar$: sChar = Parser_ViewChar(1)
If sChar = "]" Then
Call Parser_Increment
Exit Function
ElseIf sChar = "," Then
Call Parser_Increment
End If
Call Parser_Array.Add(Parser_Value())
Loop
End Function
Private Function Parser_Value() As Variant
Call Parser_SkipWS
Dim sValue$: sValue = Parser_ViewAny(1)
Select Case sValue
Case "{": Set Parser_Value = Parser_Object()
Case "[": Set Parser_Value = Parser_Array()
Case """", "'": Parser_Value = Parser_String()
Case Else
If VBA.InStr("+-0123456789", sValue) Then
Parser_Value = Parser_Numbers()
Exit Function
End If
If inputLen_ - position_ + 1 > 4 Then
sValue = Parser_ViewAny(4)
If sValue = "true" Then
Parser_Value = True
Call Parser_Increment(4)
Exit Function
ElseIf sValue = "null" Then
Parser_Value = Null
Call Parser_Increment(4)
Exit Function
End If
End If
If inputLen_ - position_ + 1 > 5 Then
sValue = Parser_ViewAny(5)
If sValue = "false" Then
Parser_Value = False
Call Parser_Increment(5)
Exit Function
End If
End If
Call Parser_RaiseError("Expected 'STRING', 'NUMBER', null, true, false, '{', or '['")
End Select
End Function
Private Function Parser_String() As String
Dim sBuffer$
Dim nBufferPosition&
Dim nBufferLen&
Dim sChar$
Dim json_Quote$: json_Quote = Parser_ConsumeChar()
Do While position_ <= inputLen_
sChar = Parser_ConsumeAny(1)
If sChar = json_Quote Then
Parser_String = BufferToString(sBuffer, nBufferPosition)
Exit Function
ElseIf sChar = "\" Then
' Escaped string, \\, or \/
sChar = Parser_ConsumeAny(1)
Select Case sChar
Case """", "\", "/", "'": Call BufferAppend(sBuffer, sChar, nBufferPosition, nBufferLen)
Case "b": Call BufferAppend(sBuffer, vbBack, nBufferPosition, nBufferLen)
Case "f": Call BufferAppend(sBuffer, vbFormFeed, nBufferPosition, nBufferLen)
Case "n": Call BufferAppend(sBuffer, vbCrLf, nBufferPosition, nBufferLen)
Case "r": Call BufferAppend(sBuffer, vbCr, nBufferPosition, nBufferLen)
Case "t": Call BufferAppend(sBuffer, vbTab, nBufferPosition, nBufferLen)
Case "u"
Dim sUTF8Char$: sUTF8Char = Parser_ViewAny(4)
Call BufferAppend(sBuffer, VBA.ChrW(VBA.val("&h" + sUTF8Char)), nBufferPosition, nBufferLen)
Call Parser_Increment(4)
Case Else: Call Parser_Increment(-1)
End Select
Else
Call BufferAppend(sBuffer, sChar, nBufferPosition, nBufferLen)
End If
Loop
End Function
Private Function Parser_Numbers() As Variant
Call Parser_SkipWS
Dim sChar$
Dim sValue$: sValue = vbNullString
Do While position_ <= inputLen_
sChar = Parser_ViewAny(1)
If VBA.InStr("+-0123456789.eE", sChar) Then
sValue = sValue & sChar
Call Parser_Increment(1)
Else
' Fix: Parse -> String, Convert -> String longer than 15/16 characters containing only numbers and decimal points -> Number
Dim bIsLarge As Boolean: bIsLarge = IIf(VBA.InStr(sValue, "."), VBA.Len(sValue) >= 17, VBA.Len(sValue) >= 16)
If bIsLarge Then
Parser_Numbers = sValue
Else
Parser_Numbers = VBA.val(sValue)
End If
Exit Function
End If
Loop
End Function
Private Function Parser_Key() As String
Dim sChar$: sChar = Parser_ViewAny(1)
If sChar = """" Or sChar = "'" Then
Parser_Key = Parser_String
Else
Call Parser_RaiseError("Expected '""' or '''")
End If
End Function
' View nChars from input
Private Function Parser_ViewAny(Optional nChars& = 1) As String
If position_ + nChars - 1 > inputLen_ Then _
Call Parser_RaiseError("Unexpected end of input")
Parser_ViewAny = VBA.Mid$(json_, position_, nChars)
End Function
' View nChar symboms (skipping spaces)
Private Function Parser_ViewChar(Optional nChars& = 1) As String
Dim nPosition&: nPosition = position_
Do While nPosition > 0 And nPosition <= inputLen_ And VBA.Mid$(json_, nPosition, 1) = " "
nPosition = nPosition + 1
Loop
If nPosition + nChars - 1 > inputLen_ Then _
Call Parser_RaiseError("Unexpected end of input")
Parser_ViewChar = VBA.Mid$(json_, nPosition, nChars)
End Function
' Consumne nChars symbols from input
Private Function Parser_ConsumeAny(Optional nChars& = 1) As String
If position_ + nChars - 1 > inputLen_ Then _
Call Parser_RaiseError("Unexpected end of input")
Parser_ConsumeAny = VBA.Mid$(json_, position_, nChars)
Call Parser_Increment(nChars)
End Function
' Consume nChars from input (skipping spaces)
Private Function Parser_ConsumeChar(Optional nChars& = 1) As String
Call Parser_SkipWS
If position_ + nChars - 1 > inputLen_ Then _
Call Parser_RaiseError("Unexpected end of input")
Parser_ConsumeChar = VBA.Mid$(json_, position_, nChars)
Call Parser_Increment(nChars)
End Function
Private Function Parser_Increment(Optional nIncrement& = 1)
position_ = position_ + nIncrement
End Function
Private Function Parser_SkipWS()
Do While position_ <= inputLen_ And VBA.Mid$(json_, position_, 1) = " "
Call Parser_Increment
Loop
End Function
Private Function Parser_RaiseError(sMessage$)
Dim nStart&: nStart = position_ - 10
If nStart <= 0 Then _
nStart = 1
Dim nEnd&: nEnd = position_ + 10
If nEnd > inputLen_ Then _
nEnd = inputLen_
Dim sErrorMsg$: sErrorMsg = "Error parsing JSON:" & VBA.vbNewLine & _
VBA.Mid$(json_, nStart, nEnd - nStart + 1) & VBA.vbNewLine & _
VBA.Space$(position_ - nStart) & "^" & VBA.vbNewLine & _
sMessage
Call Err.Raise(ERR_PARSE_FAIL, "JSONConverter", sErrorMsg)
End Function
Private Function ArraySize(target As Variant, Optional nDimension% = 1) As Long
On Error GoTo INVALID_SIZE
ArraySize = UBound(target, nDimension) - LBound(target, nDimension) + 1
Exit Function
INVALID_SIZE:
ArraySize = -1
End Function
Private Function IsUndefined(ByVal vValue As Variant) As Boolean
' Empty / Nothing -> undefined
Select Case VBA.VarType(vValue)
Case VBA.vbEmpty
IsUndefined = True
Case VBA.vbObject
Select Case VBA.TypeName(vValue)
Case "Empty", "Nothing"
IsUndefined = True
End Select
End Select
End Function
Private Function EncodeStr(ByVal sText As Variant) As String
Dim sBuffer$: sBuffer = vbNullString
Dim nBufferPosition&
Dim nBufferLen&
Dim sChar$
Dim nCharCode&
Dim nPosition&
For nPosition = 1 To VBA.Len(sText)
sChar = VBA.Mid$(sText, nPosition, 1)
nCharCode = VBA.AscW(sChar)
' Convert negative complement into char code, see: https://support.microsoft.com/en-us/kb/272138
If nCharCode < 0 Then _
nCharCode = nCharCode + 65536
Select Case nCharCode
Case 34: sChar = "\"""
Case 92: sChar = "\\"
Case 47: If escapeSlash_ Then sChar = "\/"
Case 8: sChar = "\b"
Case 12: sChar = "\f"
Case 10: sChar = "\n"
Case 13: sChar = "\r"
Case 9: sChar = "\t"
Case 0 To 31, 127 To 65535: If escapeUnicode_ Then sChar = "\u" & VBA.Right$("0000" & VBA.Hex$(nCharCode), 4)
End Select
Call BufferAppend(sBuffer, sChar, nBufferPosition, nBufferLen)
Next nPosition
EncodeStr = BufferToString(sBuffer, nBufferPosition)
End Function
Private Function IsLargeNumber(sJson As Variant) As Boolean
Dim nLength&: nLength = VBA.Len(sJson)
If nLength < 16 Or nLength >= 100 Then _
Exit Function
IsLargeNumber = True
Dim nIndex&
For nIndex = 1 To nLength Step 1
Select Case VBA.Asc(VBA.Mid$(sJson, nIndex, 1))
Case 46, 48 To 57, 69, 101 ' Look for .|0-9|E|e
' Continue through characters
Case Else
IsLargeNumber = False
Exit Function
End Select
Next nIndex
End Function
Private Function GetWS(nIdent&) As String
If Not multiline_ Then _
Exit Function
If wsCount_ <> -1 Then
GetWS = VBA.Space$(nIdent * wsCount_)
Else
GetWS = VBA.String$(nIdent, wsCharacter_)
End If
End Function
Private Function BufferAppend(ByRef sBuffer$, ByRef vAppendage As Variant, ByRef nBufferPosition&, ByRef nBufferLen&)
' Allocate a large empty string and then copy string at append position instead of allocating for each append
Dim nAppendLen&: nAppendLen = VBA.Len(vAppendage)
If nAppendLen + nBufferPosition > nBufferLen Then
Dim nAddedLength&: nAddedLength = IIf(nAppendLen > nBufferLen, nAppendLen, nBufferLen)
sBuffer = sBuffer & VBA.Space$(nAddedLength)
nBufferLen = nBufferLen + nAddedLength
End If
' Access and modify buffer using Mid$
Mid$(sBuffer, nBufferPosition + 1, nAppendLen) = CStr(vAppendage)
nBufferPosition = nBufferPosition + nAppendLen
End Function
Private Function BufferToString(ByRef sBuffer$, nBufferPosition&) As String
If nBufferPosition > 0 Then _
BufferToString = VBA.Left$(sBuffer, nBufferPosition)
End Function