feature/soap-parser #2
9
.pmomusic.yml
Normal file
9
.pmomusic.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
devices:
|
||||
mediarenderer:
|
||||
fakerenderer:
|
||||
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
|
||||
mediaserver:
|
||||
qobuz:
|
||||
udn: 28963b75-4c5f-4da7-b10e-ffafd
|
||||
host:
|
||||
http_port: "8080"
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetLevel(log.DebugLevel) // → niveau debug
|
||||
|
||||
ctx, stop := signal.NotifyContext(
|
||||
context.Background(),
|
||||
|
||||
BIN
db/chroma.sqlite3
Normal file
BIN
db/chroma.sqlite3
Normal file
Binary file not shown.
89
soap/buildsoap.go
Normal file
89
soap/buildsoap.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package soap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ----- Générateurs -----
|
||||
|
||||
// BuildUPnPResponse construit une réponse SOAP avec <ActionNameResponse>
|
||||
func BuildUPnPResponse(serviceURN, action string, values map[string]string) ([]byte, error) {
|
||||
env := &Envelope{
|
||||
XMLName: xml.Name{Local: "s:Envelope"},
|
||||
Body: Body{
|
||||
Content: buildActionResponse(serviceURN, action, values),
|
||||
},
|
||||
}
|
||||
|
||||
return marshalSOAP(env)
|
||||
}
|
||||
|
||||
// BuildSOAPFault construit un Fault SOAP standard
|
||||
func BuildSOAPFault(code, description, detail string) ([]byte, error) {
|
||||
env := &Envelope{
|
||||
XMLName: xml.Name{Local: "s:Envelope"},
|
||||
Body: Body{
|
||||
Content: buildFault(code, description, detail),
|
||||
},
|
||||
}
|
||||
|
||||
return marshalSOAP(env)
|
||||
}
|
||||
|
||||
// ----- Internes -----
|
||||
|
||||
func buildActionResponse(serviceURN, action string, values map[string]string) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(fmt.Sprintf(`<u:%sResponse xmlns:u="%s">`, action, serviceURN))
|
||||
for k, v := range values {
|
||||
buf.WriteString(fmt.Sprintf("<%s>%s</%s>", k, xmlEscape(v), k))
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf(`</u:%sResponse>`, action))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func buildFault(code, description, detail string) []byte {
|
||||
return []byte(fmt.Sprintf(`
|
||||
<Fault>
|
||||
<faultcode>%s</faultcode>
|
||||
<faultstring>%s</faultstring>
|
||||
<detail>%s</detail>
|
||||
</Fault>`, xmlEscape(code), xmlEscape(description), xmlEscape(detail)))
|
||||
}
|
||||
|
||||
func marshalSOAP(env *Envelope) ([]byte, error) {
|
||||
type soapEnvelope struct {
|
||||
XMLName xml.Name `xml:"s:Envelope"`
|
||||
SoapNS string `xml:"xmlns:s,attr"`
|
||||
EncNS string `xml:"s:encodingStyle,attr"`
|
||||
Body struct {
|
||||
XMLName xml.Name `xml:"s:Body"`
|
||||
Content string `xml:",innerxml"`
|
||||
}
|
||||
}
|
||||
|
||||
tmp := soapEnvelope{
|
||||
SoapNS: "http://schemas.xmlsoap.org/soap/envelope/",
|
||||
EncNS: "http://schemas.xmlsoap.org/soap/encoding/",
|
||||
}
|
||||
tmp.Body.Content = string(env.Body.Content)
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(`<?xml version="1.0" encoding="utf-8"?>`)
|
||||
enc := xml.NewEncoder(&buf)
|
||||
enc.Indent("", " ")
|
||||
if err := enc.Encode(tmp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc.Flush()
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// xmlEscape échappe manuellement les caractères dangereux
|
||||
func xmlEscape(s string) string {
|
||||
var buf bytes.Buffer
|
||||
xml.EscapeText(&buf, []byte(s))
|
||||
return buf.String()
|
||||
}
|
||||
@@ -3,84 +3,211 @@ package soap
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/didl"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ----- SOAP envelope -----
|
||||
|
||||
type Envelope struct {
|
||||
XMLName xml.Name `xml:"Envelope"`
|
||||
XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Envelope"`
|
||||
Header *Header `xml:"Header"`
|
||||
Body Body `xml:"Body"`
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Content []byte `xml:",innerxml"` // <- capture tout le contenu du Body sous forme de XML brut
|
||||
type Header struct {
|
||||
Content []byte `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Content []byte `xml:",innerxml"`
|
||||
}
|
||||
|
||||
// ----- UPnP request/response -----
|
||||
|
||||
type ActionRequest struct {
|
||||
Name string
|
||||
Args map[string]interface{}
|
||||
RawXML []byte
|
||||
}
|
||||
|
||||
type ActionResponse struct {
|
||||
Name string
|
||||
Values map[string]string
|
||||
RawXML []byte
|
||||
}
|
||||
|
||||
type Fault struct {
|
||||
Code string
|
||||
Description string
|
||||
Detail string
|
||||
RawXML []byte
|
||||
}
|
||||
|
||||
// ----- Utils -----
|
||||
|
||||
func prettyPrintXML(raw string) string {
|
||||
var out bytes.Buffer
|
||||
decoder := xml.NewDecoder(bytes.NewReader([]byte(raw)))
|
||||
encoder := xml.NewEncoder(&out)
|
||||
encoder.Indent("", " ") // définit l'indentation
|
||||
dec := xml.NewDecoder(bytes.NewReader([]byte(raw)))
|
||||
enc := xml.NewEncoder(&out)
|
||||
enc.Indent("", " ")
|
||||
for {
|
||||
t, err := decoder.Token()
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if err := encoder.EncodeToken(t); err != nil {
|
||||
if err := enc.EncodeToken(t); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
encoder.Flush()
|
||||
enc.Flush()
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func ParseSOAPGeneric(body []byte) {
|
||||
// ----- Parseurs -----
|
||||
|
||||
func ParseSOAPEnvelope(body []byte) (*Envelope, error) {
|
||||
var env Envelope
|
||||
if err := xml.Unmarshal(body, &env); err != nil {
|
||||
log.Warnf("❌ Failed to unmarshal SOAP Envelope: %v", err)
|
||||
return
|
||||
return nil, fmt.Errorf("unmarshal SOAP Envelope: %w", err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
decoder := xml.NewDecoder(bytes.NewReader(env.Body.Content))
|
||||
// ParamDecoder permet de transformer les valeurs des paramètres et éventuellement renommer le paramètre
|
||||
type ParamDecoder func(action, param, value string) (newParam string, out interface{}, err error)
|
||||
|
||||
// ParseUPnPAction extrait l’action et ses arguments à partir d’un Body SOAP.
|
||||
// Si decoder != nil, il est appelé pour chaque paramètre.
|
||||
func ParseUPnPAction(env *Envelope, decoder ParamDecoder) (*ActionRequest, error) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(env.Body.Content))
|
||||
var currentAction string
|
||||
args := make(map[string]interface{})
|
||||
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Warnf("❌ SOAP parse error: %v", err)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
break
|
||||
return nil, fmt.Errorf("SOAP parse error: %w", err)
|
||||
}
|
||||
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if currentAction == "" {
|
||||
currentAction = t.Name.Local // nom de l'action
|
||||
currentAction = t.Name.Local
|
||||
} else {
|
||||
var value string
|
||||
decoder.DecodeElement(&value, &t)
|
||||
|
||||
var ival interface{}
|
||||
ival, err = didl.Parse(value)
|
||||
|
||||
if err == nil {
|
||||
ival = ival.(*didl.DIDLLite).ToMarkdown()
|
||||
} else {
|
||||
ival = prettyPrintXML(value)
|
||||
if err := dec.DecodeElement(&value, &t); err != nil {
|
||||
return nil, fmt.Errorf("decode param %s: %w", t.Name.Local, err)
|
||||
}
|
||||
|
||||
args[t.Name.Local] = ival
|
||||
paramName := t.Name.Local
|
||||
var paramValue interface{} = value
|
||||
|
||||
if decoder != nil {
|
||||
if newName, out, err := decoder(currentAction, paramName, value); err == nil {
|
||||
paramName = newName
|
||||
paramValue = out
|
||||
}
|
||||
}
|
||||
|
||||
args[paramName] = paramValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("📡 SOAP Action: %s", currentAction)
|
||||
for k, v := range args {
|
||||
log.Infof(" %s = %v", k, v)
|
||||
return &ActionRequest{
|
||||
Name: currentAction,
|
||||
Args: args,
|
||||
RawXML: env.Body.Content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Response (renderer -> contrôleur)
|
||||
func ParseUPnPResponse(env *Envelope) (*ActionResponse, *Fault, error) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(env.Body.Content))
|
||||
var respName string
|
||||
values := make(map[string]string)
|
||||
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, nil, fmt.Errorf("SOAP parse error: %w", err)
|
||||
}
|
||||
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Local == "Fault" {
|
||||
// Parse fautes SOAP
|
||||
var f struct {
|
||||
Code string `xml:"faultcode"`
|
||||
Desc string `xml:"faultstring"`
|
||||
Detail string `xml:"detail"`
|
||||
}
|
||||
if err := dec.DecodeElement(&f, &t); err != nil {
|
||||
return nil, nil, fmt.Errorf("decode Fault: %w", err)
|
||||
}
|
||||
return nil, &Fault{
|
||||
Code: f.Code,
|
||||
Description: f.Desc,
|
||||
Detail: f.Detail,
|
||||
RawXML: env.Body.Content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if respName == "" {
|
||||
respName = t.Name.Local
|
||||
} else {
|
||||
var value string
|
||||
if err := dec.DecodeElement(&value, &t); err != nil {
|
||||
return nil, nil, fmt.Errorf("decode response param %s: %w", t.Name.Local, err)
|
||||
}
|
||||
values[t.Name.Local] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if respName == "" {
|
||||
return nil, nil, fmt.Errorf("no response or fault in SOAP body")
|
||||
}
|
||||
|
||||
return &ActionResponse{respName, values, env.Body.Content}, nil, nil
|
||||
}
|
||||
|
||||
// ----- Exemple -----
|
||||
|
||||
func ParseSOAPGeneric(body []byte, decoder ParamDecoder) {
|
||||
env, err := ParseSOAPEnvelope(body)
|
||||
if err != nil {
|
||||
log.Warnf("❌ %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Essayer d'abord comme requête
|
||||
if req, err := ParseUPnPAction(env, decoder); err == nil && req.Name != "" {
|
||||
log.Infof("📡 SOAP Request Action: %s", req.Name)
|
||||
for k, v := range req.Args {
|
||||
log.Infof(" %s = %v", k, v)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Sinon comme réponse
|
||||
if resp, fault, err := ParseUPnPResponse(env); err == nil {
|
||||
if resp != nil {
|
||||
log.Infof("📡 SOAP Response: %s", resp.Name)
|
||||
for k, v := range resp.Values {
|
||||
log.Infof(" %s = %v", k, v)
|
||||
}
|
||||
} else if fault != nil {
|
||||
log.Warnf("❌ SOAP Fault: %s - %s (detail: %s)", fault.Code, fault.Description, fault.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
package avtransport
|
||||
|
||||
import sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||
import (
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/didl"
|
||||
sv "gargoton.petite-maison-orange.fr/eric/pmomusic/upnp/devices/services/statevariables"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func _AVTransportURIMetaDataParser(value string) (interface{}, error) {
|
||||
log.Warnf("[avtransport] Parsing AVTransport)")
|
||||
didl, err := didl.Parse(value)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
|
||||
return didl, nil
|
||||
}
|
||||
|
||||
var AVTransportURIMetaData = func() *sv.StateVariable {
|
||||
|
||||
ts := sv.StateType_String.NewStateValue("AVTransportURIMetaData")
|
||||
ts.SetValueParser(_AVTransportURIMetaDataParser)
|
||||
|
||||
return ts
|
||||
}()
|
||||
|
||||
@@ -16,6 +16,11 @@ func (a *ActionInstance) TypeID() string {
|
||||
return "ActionInstance"
|
||||
}
|
||||
|
||||
func (a *ActionInstance) Arguments(name string) (*Argument, bool) {
|
||||
arg, ok := a.arguments[name]
|
||||
return arg, ok
|
||||
}
|
||||
|
||||
func (a *ActionInstance) ToXMLElement() *etree.Element {
|
||||
elem := etree.NewElement("action")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package statevariables
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
@@ -88,6 +89,17 @@ func (instance *StateVarInstance) AllowedValues() []interface{} {
|
||||
return instance.allowedValues
|
||||
}
|
||||
|
||||
func (instance *StateVarInstance) HasParser() bool {
|
||||
return instance.parse != nil
|
||||
}
|
||||
|
||||
func (instance *StateVarInstance) ParseValue(value string) (interface{}, error) {
|
||||
if instance.parse == nil {
|
||||
return value, errors.New("Not parsed value")
|
||||
}
|
||||
return instance.parse(value)
|
||||
}
|
||||
|
||||
// IsValueInRange checks if a value falls within the defined range.
|
||||
// Always returns true if no range is set.
|
||||
|
||||
|
||||
@@ -147,6 +147,10 @@ func (state *StateVariable) Minimum() interface{} {
|
||||
return state.valueRange.min
|
||||
}
|
||||
|
||||
func (state *StateVariable) SetValueParser(parser StringValueParser) {
|
||||
state.parse = parser
|
||||
}
|
||||
|
||||
// SetRange defines the inclusive value range [min, max].
|
||||
// Validates and casts values to the state variable's type.
|
||||
//
|
||||
|
||||
5
upnp/interface.go
Normal file
5
upnp/interface.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package upnp
|
||||
|
||||
type Markdownable interface {
|
||||
ToMarkdown() string
|
||||
}
|
||||
@@ -147,9 +147,6 @@ func (svc *ServiceInstance) EventSubHandler() func(w http.ResponseWriter, r *htt
|
||||
func (svc *ServiceInstance) ControlHandler() func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Infof("📡 Control request for %s:%s", svc.device.Name(), svc.Name())
|
||||
log.Infof("➡️ Method: %s URL: %s", r.Method, r.URL.Path)
|
||||
log.Infof("Header SOAPACTION: %s", r.Header.Get("SOAPACTION"))
|
||||
log.Infof("Header Content-Type: %s", r.Header.Get("Content-Type"))
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
@@ -158,14 +155,85 @@ func (svc *ServiceInstance) ControlHandler() func(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
soap.ParseSOAPGeneric(body)
|
||||
// Callback générique pour décoder chaque paramètre
|
||||
decoder := func(action, param, value string) (string, interface{}, error) {
|
||||
// 1️⃣ Chercher si le param correspond à une StateVarInstance
|
||||
|
||||
// Réponse minimale SOAP
|
||||
sv, ok := svc.statevariables[param]
|
||||
log.Warnf("Look for a variable named : %s -> %v", param, ok)
|
||||
|
||||
if ok {
|
||||
if sv.HasParser() {
|
||||
v, err := sv.ParseValue(value)
|
||||
if err != nil {
|
||||
return param, value, err
|
||||
}
|
||||
return param, v, err
|
||||
}
|
||||
v, err := sv.Cast(value)
|
||||
if err != nil {
|
||||
return param, value, err
|
||||
}
|
||||
return param, v, nil
|
||||
}
|
||||
|
||||
// 2️⃣ Chercher si le param correspond à une ActionInstance et appliquer un parseur associé
|
||||
act, ok := svc.actions[action]
|
||||
log.Debugf("Look for an action named : %s -> %v", action, ok)
|
||||
|
||||
if ok {
|
||||
if argument, ok := act.Arguments(param); ok {
|
||||
sv_name := argument.StateVariable().Name()
|
||||
sv := svc.statevariables[sv_name]
|
||||
v, err := sv.ParseValue(value)
|
||||
|
||||
log.Debugf("It corresponds to variable : %s with a parser %v", sv_name, sv.HasParser())
|
||||
|
||||
if err != nil {
|
||||
return param, value, err
|
||||
}
|
||||
return param, v, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ Sinon, valeur brute
|
||||
return param, value, nil
|
||||
}
|
||||
|
||||
env, err := soap.ParseSOAPEnvelope(body)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("❌ Failed to parse SOAP enveloppe: %v", err)
|
||||
soapResp, _ := soap.BuildSOAPFault("s:Client", "Invalid Args", err.Error())
|
||||
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write(soapResp)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := soap.ParseUPnPAction(env, decoder)
|
||||
if err != nil {
|
||||
log.Errorf("❌ Failed to parse SOAP Action: %v", err)
|
||||
soapResp, _ := soap.BuildSOAPFault("s:Client", "Invalid Args", err.Error())
|
||||
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write(soapResp)
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("➡️ SOAP Action: %s", req.Name)
|
||||
for k, v := range req.Args {
|
||||
if mi, ok := v.(Markdownable); ok {
|
||||
v = mi.ToMarkdown()
|
||||
}
|
||||
log.Infof(" %s:%s = %v", req.Name, k, v)
|
||||
}
|
||||
|
||||
// Ici tu peux appeler l'action correspondante sur svc.actions[req.Name] et récupérer le résultat
|
||||
// Exemple de réponse minimale :
|
||||
resp, _ := soap.BuildUPnPResponse(svc.ServiceType(), req.Name, map[string]string{})
|
||||
w.Header().Set("Content-Type", `text/xml; charset="utf-8"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body/>
|
||||
</s:Envelope>`))
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user