Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `internal/soap` codec for the KAS-API envelope: `Value` discriminated
union mirroring the Apache xml-soap `ns2:Map` shape (xsi:type:
string/int/float/boolean, ns2:Map, SOAP-ENC:Array), `Decode` for
`KasApiResponse`/`SOAP-ENV:Fault` envelopes returning `*Response` or
`*FaultError`, and `EncodeRequest` for the JSON-in-`<Params>` request
envelope. Table-driven tests cover 471 response fixtures plus shape
pins and encoder validation. (`testdata/session/` is left for the
KasAuth client in issue #5.)
- Bootstrap Go module `github.com/chmmou/kasapi-cli` (Go 1.23).
- `cmd/kasapi-cli` entry point with build-stamped `--version`.
- `internal/` package skeleton mirroring the clean-architecture layering in
Expand Down
14 changes: 12 additions & 2 deletions internal/soap/doc.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
// Package soap implements the Apache xml-soap ns2:Map codec used by every
// KAS-API response. See issue #3.
// Package soap implements the codec for the KAS-API SOAP shape.
//
// Responses use the Apache xml-soap "ns2:Map" representation: every value
// carries an explicit xsi:type discriminator (xsd:string / xsd:int /
// xsd:float / xsd:boolean / ns2:Map / SOAP-ENC:Array). The package exposes
// a Value type that mirrors that shape and a Decode entry point for the
// SOAP envelope. SOAP-ENV:Fault bodies surface as *FaultError.
//
// Requests are a JSON payload wrapped in <tns:KasApi><Params>{json}</Params>.
// EncodeRequest produces a valid envelope from the typed Request struct.
//
// See issue #3 for the original design.
package soap
196 changes: 196 additions & 0 deletions internal/soap/envelope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package soap

import (
"encoding/xml"
"errors"
"fmt"
"io"
)

// Response is the parsed body of a successful KasApi SOAP envelope. It
// keeps both the typed shortcuts (KasFloodDelay, ReturnString, ReturnInfo)
// and the full ordered Map for callers needing extras.
type Response struct {
Request Value
Body ResponseBody
RawReturn Value
}

// ResponseBody mirrors the canonical fields under <return><Response>.
type ResponseBody struct {
KasFloodDelay float64
ReturnString string
ReturnInfo Value
Msg Value
Raw []KV
}

// Fault is the parsed payload of a SOAP-ENV:Fault element.
type Fault struct {
Code string
String string
Actor string
Detail string
}

// FaultError wraps a Fault so it can be returned as a Go error.
type FaultError struct {
Fault Fault
}

func (e *FaultError) Error() string {
if e.Fault.Detail != "" {
return fmt.Sprintf("kas-api fault %q: %s", e.Fault.String, e.Fault.Detail)
}
return fmt.Sprintf("kas-api fault %q", e.Fault.String)
}

// Decode parses a KasApi SOAP envelope. It returns a typed Response on
// success and a *FaultError when the body contained a SOAP-ENV:Fault.
func Decode(r io.Reader) (*Response, error) {
dec := xml.NewDecoder(r)
for {
tok, err := dec.Token()
if err == io.EOF {
return nil, errors.New("soap: empty document")
}
if err != nil {
return nil, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local == "Body" {
return decodeBody(dec, start)
}
}
}

func decodeBody(d *xml.Decoder, parent xml.StartElement) (*Response, error) {
for {
tok, err := d.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "KasApiResponse":
return decodeKasApiResponse(d, t)
case "Fault":
fault, err := decodeFault(d, t)
if err != nil {
return nil, err
}
return nil, &FaultError{Fault: *fault}
default:
if err := d.Skip(); err != nil {
return nil, err
}
}
case xml.EndElement:
if t.Name == parent.Name {
return nil, errors.New("soap: empty Body")
}
}
}
}

func decodeKasApiResponse(d *xml.Decoder, parent xml.StartElement) (*Response, error) {
for {
tok, err := d.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Local == "return" {
var v Value
if err := v.UnmarshalXML(d, t); err != nil {
return nil, err
}
return buildResponse(v)
}
if err := d.Skip(); err != nil {
return nil, err
}
case xml.EndElement:
if t.Name == parent.Name {
return nil, errors.New("soap: missing <return> element")
}
}
}
}

func buildResponse(top Value) (*Response, error) {
if top.Kind != KindMap {
return nil, fmt.Errorf("soap: <return> is not a Map (kind=%d)", top.Kind)
}
out := &Response{RawReturn: top}
for _, kv := range top.Map {
switch kv.Key {
case "Request":
out.Request = kv.Value
case "Response":
body, err := buildResponseBody(kv.Value)
if err != nil {
return nil, err
}
out.Body = body
}
}
return out, nil
}

func buildResponseBody(v Value) (ResponseBody, error) {
var out ResponseBody
if v.Kind != KindMap {
return out, fmt.Errorf("soap: Response is not a Map (kind=%d)", v.Kind)
}
out.Raw = v.Map
for _, kv := range v.Map {
switch kv.Key {
case "KasFloodDelay":
out.KasFloodDelay = kv.Value.AsFloat()
case "ReturnString":
out.ReturnString = kv.Value.AsString()
case "ReturnInfo":
out.ReturnInfo = kv.Value
case "Msg":
out.Msg = kv.Value
}
}
return out, nil
}

func decodeFault(d *xml.Decoder, parent xml.StartElement) (*Fault, error) {
out := &Fault{}
for {
tok, err := d.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
s, err := readCharData(d, t)
if err != nil {
return nil, err
}
switch t.Name.Local {
case "faultcode":
out.Code = s
case "faultstring":
out.String = s
case "faultactor":
out.Actor = s
case "detail":
out.Detail = s
}
case xml.EndElement:
if t.Name == parent.Name {
return out, nil
}
}
}
}
67 changes: 67 additions & 0 deletions internal/soap/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package soap

import (
"encoding/json"
"fmt"
"io"
)

// AuthType enumerates the kas_auth_type values the KAS API accepts.
type AuthType string

// Auth types per https://kasapi.kasserver.com/dokumentation/phpdoc/.
const (
AuthPlain AuthType = "plain"
AuthSession AuthType = "session"
)

// Request is the typed payload for a KasApi call. It is encoded as JSON
// inside the <Params> element of the SOAP envelope.
type Request struct {
Login string
AuthType AuthType
AuthData string
Action string
Params map[string]any
}

const requestTemplate = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="https://kasserver.com/">
<soapenv:Body>
<tns:KasApi>
<Params>%s</Params>
</tns:KasApi>
</soapenv:Body>
</soapenv:Envelope>`

// EncodeRequest writes a SOAP request envelope for a KasApi call. The
// payload is serialized as JSON; json.Marshal HTML-escapes <, > and &, so
// the JSON body is always safe inside the XML <Params> element.
func EncodeRequest(w io.Writer, r Request) error {
if r.Action == "" {
return fmt.Errorf("soap: Request.Action is required")
}
if r.Login == "" {
return fmt.Errorf("soap: Request.Login is required")
}
if r.AuthType == "" {
return fmt.Errorf("soap: Request.AuthType is required")
}
params := r.Params
if params == nil {
params = map[string]any{}
}
payload := map[string]any{
"KasRequestParams": params,
"kas_action": r.Action,
"kas_auth_data": r.AuthData,
"kas_auth_type": string(r.AuthType),
"kas_login": r.Login,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("soap: marshal params: %w", err)
}
_, err = fmt.Fprintf(w, requestTemplate, body)
return err
}
Loading
Loading