fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation

This commit is contained in:
2026-07-10 17:36:26 +08:00
parent 2b4c9d9dcb
commit b30709de3d
84 changed files with 5291 additions and 982 deletions
+21
View File
@@ -0,0 +1,21 @@
codegen
=======
> A util tool to generate a client from the Swagger spec of Nakama's server API.
## Usage
```shell
go run main.go "$GOPATH/src/github.com/heroiclabs/nakama/apigrpc/apigrpc.swagger.json" > ../addons/com.heroiclabs.nakama/api/NakamaAPI.gd
```
### Rationale
We want to maintain a simple lean low level client within our GDScript client which has minimal dependencies so we built our own. This gives us complete control over the dependencies required and structure of the code generated.
The generated code is designed to be supported Godot Engine `3.1+`.
### Limitations
The code generator has __only__ been checked against the Swagger specification generated for Nakama server. YMMV.
+639
View File
@@ -0,0 +1,639 @@
// Copyright 2018 The Nakama Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"os"
"strings"
"text/template"
)
var utilities = map[string]string {
"ApiAccount":
`
var _wallet_dict = null
var wallet_dict : Dictionary:
get:
if _wallet_dict == null:
if _wallet == null:
return {}
var json = JSON.new()
if json.parse(_wallet) != OK:
return {}
_wallet_dict = json.get_data()
return _wallet_dict as Dictionary
`,
}
const codeTemplate string = `### Code generated by codegen/main.go. DO NOT EDIT. ###
extends RefCounted
class_name NakamaAPI
{{- range $defname, $definition := .Definitions }}
{{- $classname := $defname | title }}
{{- if isRefToEnum $classname }}
# {{ enumSummary $definition | stripNewlines }}
{{- range $idx, $val := ($definition | enumDescriptions) }}
# {{ $val }}
{{- end -}}
# {{ $definition | enumDescriptions }}
enum {{ $classname | title }} { {{- range $idx, $enum := $definition.Enum }}{{ $enum }} = {{ $idx }},{{- end -}} }
{{- else }}
# {{ $definition.Description | stripNewlines }}
class {{ $classname }} extends NakamaAsyncResult:
const _SCHEMA = {
{{- range $propname, $property := $definition.Properties }}
{{- $fieldname := $propname | pascalToSnake }}
{{- $_field := printf "_%s" $fieldname }}
{{- $gdType := godotType $property.Type $property.Ref $property.Items.Type $property.Items.Ref (isRefToEnum (cleanRef $property.Ref)) }}
"{{ $fieldname }}": {"name": "{{ $_field }}", "type": {{ $gdType | godotSchemaType }}, "required": false
{{- if eq $property.Type "array" -}}
, "content": {{ (godotType $property.Items.Type $property.Items.Ref "" "" false) | godotSchemaType }}
{{- else if eq $property.Type "object" -}}
, "content": {{ (godotType $property.AdditionalProperties.Type "" "" "" false) | godotSchemaType }}
{{- end -}}
},
{{- end }}
}
{{- range $propname, $property := $definition.Properties }}
{{- $fieldname := $propname | pascalToSnake }}
{{- $_field := printf "_%s" $fieldname }}
{{- $gdType := godotType $property.Type $property.Ref $property.Items.Type $property.Items.Ref (isRefToEnum (cleanRef $property.Ref)) }}
{{- $gdDef := $gdType | godotDef }}
# {{ $property.Description }}
var {{ $_field }}
var {{ $fieldname }} : {{ $gdType }}:
get:
{{- if $property.Ref }}
{{- if isRefToEnum (cleanRef $property.Ref) }}{{/* Enums */}}
return {{ cleanRef $property.Ref }}.values()[0] if not {{ cleanRef $property.Ref }}.values().has({{ $_field }}) else {{ $_field }}
{{- else }}{{/* Object reference */}}
return _{{ $fieldname }} as {{ $gdType }}
{{- end }}
{{- else if eq $property.Type "object"}}{{/* Dictionaries */}}
return Dictionary() if not {{ $_field }} is Dictionary else {{ $_field }}.duplicate()
{{- else }}{{/* Simple type */}}
return {{ $gdDef }} if not {{ $_field }} is {{ $gdType }} else {{ $gdType }}({{ $_field }})
{{- end }}
{{- end }}
{{- godotClassUtils $classname }}
func _init(p_exception = null):
super(p_exception)
static func create(p_ns : GDScript, p_dict : Dictionary) -> {{ $classname }}:
return _safe_ret(NakamaSerializer.deserialize(p_ns, "{{ $classname }}", p_dict), {{ $classname }}) as {{ $classname }}
func serialize() -> Dictionary:
return NakamaSerializer.serialize(self)
func _to_string() -> String:
if is_exception():
return get_exception()._to_string()
var output : String = ""
{{- range $propname, $property := $definition.Properties }}
{{- $fieldname := $propname | pascalToSnake }}
{{- $_field := printf "_%s" $fieldname }}
{{- if eq $property.Type "array" }}
output += "{{ $fieldname }}: %s, " % [{{ $_field }}]
{{- else if eq $property.Type "object" }}
var map_string : String = ""
if typeof({{ $_field }}) == TYPE_DICTIONARY:
for k in {{ $_field }}:
map_string += "{%s=%s}, " % [k, {{ $_field }}[k]]
output += "{{ $fieldname }}: [%s], " % map_string
{{- else }}
output += "{{ $fieldname }}: %s, " % {{ $_field }}
{{- end }}
{{- end }}
return output
{{- end }}
{{- end }}
# The low level client for the Nakama API.
class ApiClient extends RefCounted:
var _base_uri : String
var _http_adapter
var _namespace : GDScript
var _server_key : String
var auto_refresh := true
var auto_refresh_time := 300
var auto_retry : bool:
set(p_value):
_http_adapter.auto_retry = p_value
get:
return _http_adapter.auto_retry
var auto_retry_count : int:
set(p_value):
_http_adapter.auto_retry_count = p_value
get:
return _http_adapter.auto_retry_count
var auto_retry_backoff_base : int:
set(p_value):
_http_adapter.auto_retry_backoff_base = p_value
get:
return _http_adapter.auto_retry_backoff_base
var last_cancel_token:
get:
return _http_adapter.get_last_token()
func _init(p_base_uri : String, p_http_adapter, p_namespace : GDScript, p_server_key : String, p_timeout : int = 10):
_base_uri = p_base_uri
_http_adapter = p_http_adapter
_http_adapter.timeout = p_timeout
_namespace = p_namespace
_server_key = p_server_key
func _refresh_session(p_session : NakamaSession):
if auto_refresh and p_session.is_valid() and p_session.refresh_token and not p_session.is_refresh_expired() and p_session.would_expire_in(auto_refresh_time):
var request = ApiSessionRefreshRequest.new()
request._token = p_session.refresh_token
return await session_refresh_async(_server_key, "", request)
return null
func cancel_request(p_token):
if p_token:
_http_adapter.cancel_request(p_token)
{{- range $url, $path := .Paths }}
{{- range $method, $operation := $path}}
# {{ $operation.Summary | stripNewlines }}
{{- if $operation.Responses.Ok.Schema.Ref }}
func {{ $operation.OperationId | apiFuncName }}_async(
{{- else }}
func {{ $operation.OperationId | apiFuncName }}_async(
{{- end}}
{{- if $operation.Security }}
{{- with (index $operation.Security 0) }}
{{- range $key, $value := . }}
{{- if eq $key "BasicAuth" }}
p_basic_auth_username : String
, p_basic_auth_password : String
{{- else if eq $key "HttpKeyAuth" }}
p_bearer_token : String
{{- end }}
{{- end }}
{{- end }}
{{- else }}
p_session : NakamaSession
{{- end }}
{{- range $parameter := $operation.Parameters }}
{{- $argument := $parameter.Name | prependParameter }}
{{- if not $parameter.Required }}{{/* Godot does not support typed optional parameters yet. */}}
, {{ $argument }} = null # : {{ $parameter.Type }}
{{- else if eq $parameter.In "body" }}
{{- if eq $parameter.Schema.Type "string" }}
, {{ $argument }} : String
{{- else }}
, {{ $argument }} : {{ $parameter.Schema.Ref | cleanRef }}
{{- end }}
{{- else }}
, {{ $argument }} : {{ godotType $parameter.Type $parameter.Schema.Ref $parameter.Items.Type "" (isRefToEnum (cleanRef $parameter.Schema.Ref)) }}
{{- end }}
{{- end }}
)
{{- if $operation.Responses.Ok.Schema.Ref }} -> {{ $operation.Responses.Ok.Schema.Ref | cleanRef }}
{{- else }} -> NakamaAsyncResult
{{- end }}:
{{- $classname := "NakamaAsyncResult" }}
{{- if $operation.Responses.Ok.Schema.Ref }}
{{- $classname = $operation.Responses.Ok.Schema.Ref | cleanRef }}
{{- end }}
{{- if not $operation.Security }}
var try_refresh = await _refresh_session(p_session)
if try_refresh != null:
if try_refresh.is_exception():
return {{ $classname }}.new(try_refresh.get_exception())
await p_session.refresh(try_refresh)
{{- end }}
var urlpath : String = "{{- $url }}"
{{- range $parameter := $operation.Parameters }}
{{- $argument := $parameter.Name | prependParameter }}
{{- if eq $parameter.In "path" }}
urlpath = urlpath.replace("{{- print "{" $parameter.Name "}"}}", NakamaSerializer.escape_http({{ $argument }}))
{{- end }}
{{- end }}
var query_params = ""
{{- range $parameter := $operation.Parameters }}
{{- $argument := $parameter.Name | prependParameter }}
{{- $snakecase := $parameter.Name | pascalToSnake }}
{{- if eq $parameter.In "query"}}
{{- if $parameter.Required }}
if true: # Hack for static checks
{{- else }}
if {{ $argument }} != null:
{{- end }}
{{- if eq $parameter.Type "integer" }}
query_params += "{{- $snakecase }}=%d&" % {{ $argument }}
{{- else if eq $parameter.Type "string" }}
query_params += "{{- $snakecase }}=%s&" % NakamaSerializer.escape_http({{ $argument }})
{{- else if eq $parameter.Type "boolean" }}
query_params += "{{- $snakecase }}=%s&" % str(bool({{ $argument }})).to_lower()
{{- else if eq $parameter.Type "array" }}
for elem in {{ $argument }}:
query_params += "{{- $snakecase }}=%s&" % elem
{{- else }}
{{ $parameter }} // ERROR
{{- end }}
{{- end }}
{{- end }}
var uri = "%s%s%s" % [_base_uri, urlpath, "?" + query_params if query_params else ""]
var method = "{{- $method | uppercase }}"
var headers = {}
{{- if $operation.Security }}
{{- with (index $operation.Security 0) }}
{{- range $key, $value := . }}
{{- if eq $key "BasicAuth" }}
var credentials = Marshalls.utf8_to_base64(p_basic_auth_username + ":" + p_basic_auth_password)
var header = "Basic %s" % credentials
headers["Authorization"] = header
{{- else if eq $key "HttpKeyAuth" }}
if (p_bearer_token):
var header = "Bearer %s" % p_bearer_token
headers["Authorization"] = header
{{- end }}
{{- end }}
{{- end }}
{{- else }}
var header = "Bearer %s" % p_session.token
headers["Authorization"] = header
{{- end }}
var content : PackedByteArray
{{- range $parameter := $operation.Parameters }}
{{- $argument := $parameter.Name | prependParameter }}
{{- if eq $parameter.In "body" }}
{{- if eq $parameter.Schema.Type "string" }}
content = JSON.stringify({{ $argument }}).to_utf8_buffer()
{{- else }}
content = JSON.stringify({{ $argument }}.serialize()).to_utf8_buffer()
{{- end }}
{{- end }}
{{- end }}
var result = await _http_adapter.send_async(method, uri, headers, content)
if result is NakamaException:
return {{ $classname }}.new(result)
{{- if $operation.Responses.Ok.Schema.Ref }}
var out : {{ $classname }} = NakamaSerializer.deserialize(_namespace, "{{ $classname }}", result)
return out
{{- else }}
return NakamaAsyncResult.new()
{{- end}}
{{- end }}
{{- end }}
`
func convertRefToClassName(input string) (className string) {
cleanRef := strings.TrimPrefix(input, "#/definitions/")
className = strings.Title(cleanRef)
return
}
func stripNewlines(input string) (output string) {
output = strings.Replace(input, "\n", " ", -1)
return
}
func prependParameter(input string) (output string) {
output = "p_" + pascalToSnake(input)
return
}
func pascalToSnake(input string) (output string) {
output = ""
prev_low := false
for _, v := range input {
is_cap := v >= 'A' && v <= 'Z'
is_low := v >= 'a' && v <= 'z'
if is_cap && prev_low {
output = output + "_"
}
output += strings.ToLower(string(v))
prev_low = is_low
}
return
}
func apiFuncName(input string) (output string) {
output = pascalToSnake(input[7:])
return
}
func godotType(p_type string, p_ref string, p_item_type string, p_extra string, p_is_enum bool) (out string) {
is_array := false
is_dict := false
switch p_type {
case "integer":
out = "int"
case "string":
out = "String"
case "boolean":
out = "bool"
case "array":
is_array = true
case "object":
is_dict = true
default:
if p_is_enum {
out = "int"
} else {
out = convertRefToClassName(p_ref)
}
}
if is_array {
switch p_item_type {
case "integer":
out = "PackedIntArray"
return
case "string":
out = "PackedStringArray"
return
case "boolean":
out = "PackedIntArray"
return
default:
out = "Array"
}
}
if is_dict {
out = "Dictionary"
}
return
}
func godotDef(p_type string) (out string) {
switch(p_type) {
case "bool": out = "false"
case "int": out = "0"
case "String": out = "\"\""
case "PackedIntArray": out = "PackedIntArray()"
case "PackedStringArray": out = "PackedStringArray()"
case "Array": out = "Array()"
case "Dictionary": out = "Dictionary()"
}
return
}
func godotLooseType(p_type string) (out string) {
switch(p_type) {
case "PackedStringArray", "PackedIntArray":
out = "Array"
default:
out = p_type
}
return
}
func godotSchemaType(p_type string) (out string) {
out = "TYPE_"
switch(p_type) {
case "bool": out += "BOOL"
case "int": out += "INT"
case "String": out += "STRING"
case "PackedIntArray": out += "ARRAY"
case "PackedStringArray": out += "ARRAY"
case "Array": out += "ARRAY"
case "Dictionary": out += "DICTIONARY"
default: out = "\"" + p_type + "\""
}
return
}
func pascalToCamel(input string) (camelCase string) {
if input == "" {
return ""
}
camelCase = strings.ToLower(string(input[0]))
camelCase += string(input[1:])
return camelCase
}
func camelToPascal(camelCase string) (pascalCase string) {
if len(camelCase) <= 0 {
return ""
}
pascalCase = strings.ToUpper(string(camelCase[0])) + camelCase[1:]
return
}
func enumSummary(def Definition) string {
// quirk of swagger generation: if enum doesn't have a title
// then the title can be found as the first entry in the split description.
if def.Title != "" {
return def.Title
}
split := strings.Split(def.Description, "\n")
if len(split) <= 0 {
panic("No newlines in enum description found.")
}
return split[0]
}
func enumDescriptions(def Definition) (output []string) {
split := strings.Split(def.Description, "\n")
if len(split) <= 0 {
panic("No newlines in enum description found.")
}
if def.Title != "" {
return split
}
// quirk of swagger generation: if enum doesn't have a title
// then the title can be found as the first entry in the split description.
// so ignore for individual enum descriptions.
return split[2:]
}
type Definition struct {
Properties map[string]struct {
Type string
Ref string `json:"$ref"` // used with object
Items struct { // used with type "array"
Type string
Ref string `json:"$ref"`
}
AdditionalProperties struct {
Type string // used with type "map"
}
Format string // used with type "boolean"
Description string
}
Enum []string
Description string
// used only by enums
Title string
}
func godotClassUtils(p_name string) string {
if val, ok := utilities[p_name]; ok {
return val
}
return ""
}
func main() {
// Argument flags
var output = flag.String("output", "", "The output for generated code.")
flag.Parse()
inputs := flag.Args()
if len(inputs) < 1 {
fmt.Printf("No input file found: %s\n\n", inputs)
fmt.Println("openapi-gen [flags] inputs...")
flag.PrintDefaults()
return
}
input := inputs[0]
content, err := ioutil.ReadFile(input)
if err != nil {
fmt.Printf("Unable to read file: %s\n", err)
return
}
var schema struct {
Paths map[string]map[string]struct {
Summary string
OperationId string
Responses struct {
Ok struct {
Schema struct {
Ref string `json:"$ref"`
}
} `json:"200"`
}
Parameters []struct {
Name string
In string
Required bool
Type string // used with primitives
Items struct { // used with type "array"
Type string
}
Schema struct { // used with http body
Type string
Ref string `json:"$ref"`
}
Format string // used with type "boolean"
}
Security []map[string][]struct {
}
}
Definitions map[string]Definition
}
if err := json.Unmarshal(content, &schema); err != nil {
fmt.Printf("Unable to decode input %s : %s\n", input, err)
return
}
fmap := template.FuncMap{
"cleanRef": convertRefToClassName,
"stripNewlines": stripNewlines,
"title": strings.Title,
"uppercase": strings.ToUpper,
"prependParameter": prependParameter,
"pascalToSnake": pascalToSnake,
"apiFuncName": apiFuncName,
"godotType": godotType,
"godotLooseType": godotLooseType,
"godotSchemaType": godotSchemaType,
"godotDef": godotDef,
"isRefToEnum": func(ref string) bool {
if len(ref) == 0 {
return false
}
// swagger schema definition keys have inconsistent casing
var camelOk bool
var pascalOk bool
var enums []string
asCamel := pascalToCamel(ref)
if _, camelOk = schema.Definitions[asCamel]; camelOk {
enums = schema.Definitions[asCamel].Enum
}
asPascal := camelToPascal(ref)
if _, pascalOk = schema.Definitions[asPascal]; pascalOk {
enums = schema.Definitions[asPascal].Enum
}
if !pascalOk && !camelOk {
fmt.Printf("no definition found: %v", ref)
return false
}
return len(enums) > 0
},
"enumDescriptions": enumDescriptions,
"enumSummary": enumSummary,
"godotClassUtils": godotClassUtils,
}
tmpl, err := template.New(input).Funcs(fmap).Parse(codeTemplate)
if err != nil {
fmt.Printf("Template parse error: %s\n", err)
return
}
if len(*output) < 1 {
tmpl.Execute(os.Stdout, schema)
return
}
f, err := os.Create(*output)
if err != nil {
fmt.Printf("Unable to create file: %s\n", err)
return
}
defer f.Close()
writer := bufio.NewWriter(f)
tmpl.Execute(writer, schema)
writer.Flush()
}