diff --git a/client.go b/client.go index ed3309e6..80f2e8f7 100644 --- a/client.go +++ b/client.go @@ -79,6 +79,19 @@ type Configuration interface { Metrics() RequestMetricsHook } +// RedirectPolicy controls how an HTTP client handles a redirect response. +type RedirectPolicy func(req *http.Request, via []*http.Request) error + +// RedirectPolicyProvider optionally supplies a redirect policy for one configuration. +type RedirectPolicyProvider interface { + RedirectPolicy() RedirectPolicy +} + +// RejectRedirects returns the original redirect response without following it. +func RejectRedirects(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse +} + // SelectHeaderAccept join all accept types and return func SelectHeaderAccept(accepts []string) string { if len(accepts) == 0 { @@ -162,32 +175,44 @@ func CallAPI(cfg Configuration, request *http.Request) (*http.Response, error) { start := time.Now() metricHook := cfg.Metrics() - if cfg.HTTPClient() != nil { - resp, err := cfg.HTTPClient().Do(request) - if metricHook != nil { - metricHook(request.Method, getServiceNameFromUrl(request.URL.Path), getRespStatusCode(resp), - time.Since(start).Seconds()) - } - return resp, err + client, err := selectHTTPClient(cfg, request) + if err != nil { + return nil, err } - if request.URL.Scheme == "https" { - resp, err := innerHTTP2Client.Do(request) - if metricHook != nil { - metricHook(request.Method, getServiceNameFromUrl(request.URL.Path), getRespStatusCode(resp), - time.Since(start).Seconds()) - } - return resp, err - } else if request.URL.Scheme == "http" { - resp, err := innerHTTP2CleartextClient.Do(request) - if metricHook != nil { - metricHook(request.Method, getServiceNameFromUrl(request.URL.Path), getRespStatusCode(resp), - time.Since(start).Seconds()) + resp, err := client.Do(request) + if metricHook != nil { + metricHook(request.Method, getServiceNameFromUrl(request.URL.Path), getRespStatusCode(resp), + time.Since(start).Seconds()) + } + return resp, err +} + +func selectHTTPClient(cfg Configuration, request *http.Request) (*http.Client, error) { + client := cfg.HTTPClient() + if client == nil { + switch request.URL.Scheme { + case "https": + client = innerHTTP2Client + case "http": + client = innerHTTP2CleartextClient + default: + return nil, fmt.Errorf("unsupported scheme[%s]", request.URL.Scheme) } - return resp, err } - return nil, fmt.Errorf("unsupported scheme[%s]", request.URL.Scheme) + provider, ok := cfg.(RedirectPolicyProvider) + if !ok { + return client, nil + } + policy := provider.RedirectPolicy() + if policy == nil { + return client, nil + } + + requestClient := *client + requestClient.CheckRedirect = policy + return &requestClient, nil } func getServiceNameFromUrl(path string) string { diff --git a/client_test.go b/client_test.go index c48ebf59..cb57f763 100644 --- a/client_test.go +++ b/client_test.go @@ -1,11 +1,48 @@ package openapi import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "reflect" + "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/require" ) +type clientTestRoundTripper func(*http.Request) (*http.Response, error) + +func (f clientTestRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +type clientTestConfiguration struct { + httpClient *http.Client + metrics RequestMetricsHook +} + +func (c *clientTestConfiguration) BasePath() string { return "" } +func (c *clientTestConfiguration) Host() string { return "" } +func (c *clientTestConfiguration) UserAgent() string { return "" } +func (c *clientTestConfiguration) DefaultHeader() map[string]string { return nil } +func (c *clientTestConfiguration) HTTPClient() *http.Client { return c.httpClient } +func (c *clientTestConfiguration) Metrics() RequestMetricsHook { return c.metrics } + +type redirectClientTestConfiguration struct { + *clientTestConfiguration + policy RedirectPolicy +} + +func (c *redirectClientTestConfiguration) RedirectPolicy() RedirectPolicy { + return c.policy +} + func TestParameterToString(t *testing.T) { var testCases = []struct { name string @@ -89,3 +126,285 @@ func TestMultipartDeserialize_MalformedBody(t *testing.T) { }) } } + +func TestCallAPIPreservesFollowBehaviorWithoutPolicy(t *testing.T) { + tests := []struct { + name string + newConfig func(*http.Client) Configuration + }{ + { + name: "provider absent", + newConfig: func(client *http.Client) Configuration { + return &clientTestConfiguration{httpClient: client} + }, + }, + { + name: "nil policy", + newConfig: func(client *http.Client) Configuration { + return &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{httpClient: client}, + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var requests atomic.Int32 + client := &http.Client{ + Transport: clientTestRoundTripper(func(request *http.Request) (*http.Response, error) { + requests.Add(1) + if request.URL.Host == "origin.example.com" { + return clientTestResponse(request, http.StatusTemporaryRedirect, http.Header{ + "Location": []string{"https://target.example.com/resource"}, + }), nil + } + return clientTestResponse(request, http.StatusNoContent, nil), nil + }), + } + request, err := http.NewRequest(http.MethodGet, "https://origin.example.com/resource", nil) + require.NoError(t, err) + + response, err := CallAPI(test.newConfig(client), request) + + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, response.StatusCode) + require.NoError(t, response.Body.Close()) + require.Equal(t, int32(2), requests.Load()) + require.Nil(t, client.CheckRedirect) + }) + } +} + +func TestSelectHTTPClientPreservesSelectedClient(t *testing.T) { + t.Run("explicit client takes precedence", func(t *testing.T) { + transport := clientTestRoundTripper(func(request *http.Request) (*http.Response, error) { + return clientTestResponse(request, http.StatusNoContent, nil), nil + }) + jar, err := cookiejar.New(nil) + require.NoError(t, err) + originalRedirectError := errors.New("original redirect policy") + originalRedirectPolicy := func(_ *http.Request, _ []*http.Request) error { + return originalRedirectError + } + original := &http.Client{ + Transport: transport, + CheckRedirect: originalRedirectPolicy, + Jar: jar, + Timeout: 23 * time.Second, + } + configuration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{httpClient: original}, + policy: RejectRedirects, + } + request, err := http.NewRequest(http.MethodGet, "custom://origin.example.com/resource", nil) + require.NoError(t, err) + + selected, err := selectHTTPClient(configuration, request) + + require.NoError(t, err) + require.NotSame(t, original, selected) + requireSameHTTPClientSettings(t, original, selected) + require.ErrorIs(t, selected.CheckRedirect(nil, nil), http.ErrUseLastResponse) + require.ErrorIs(t, original.CheckRedirect(nil, nil), originalRedirectError) + }) + + for _, test := range []struct { + name string + scheme string + original *http.Client + }{ + {name: "shared HTTPS client", scheme: "https", original: innerHTTP2Client}, + {name: "shared cleartext client", scheme: "http", original: innerHTTP2CleartextClient}, + } { + t.Run(test.name, func(t *testing.T) { + configuration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{}, + policy: RejectRedirects, + } + request, err := http.NewRequest(http.MethodGet, test.scheme+"://origin.example.com/resource", nil) + require.NoError(t, err) + + selected, err := selectHTTPClient(configuration, request) + + require.NoError(t, err) + require.NotSame(t, test.original, selected) + requireSameHTTPClientSettings(t, test.original, selected) + require.ErrorIs(t, selected.CheckRedirect(nil, nil), http.ErrUseLastResponse) + require.Nil(t, test.original.CheckRedirect) + }) + } +} + +func TestCallAPIRedirectPolicyPreservesMetrics(t *testing.T) { + var metricCalls atomic.Int32 + var metricStatus atomic.Int32 + var metricMethod string + var metricPath string + client := &http.Client{ + Transport: clientTestRoundTripper(func(request *http.Request) (*http.Response, error) { + return clientTestResponse(request, http.StatusTemporaryRedirect, http.Header{ + "Location": []string{"https://target.example.com/resource"}, + }), nil + }), + } + configuration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{ + httpClient: client, + metrics: func(method string, path string, status int, _ float64) { + metricCalls.Add(1) + metricStatus.Store(int32(status)) + metricMethod = method + metricPath = path + }, + }, + policy: RejectRedirects, + } + request, err := http.NewRequest(http.MethodGet, "https://origin.example.com/nupf-ee/v1/resource", nil) + require.NoError(t, err) + + response, err := CallAPI(configuration, request) + + require.NoError(t, err) + require.Equal(t, http.StatusTemporaryRedirect, response.StatusCode) + require.NoError(t, response.Body.Close()) + require.Equal(t, int32(1), metricCalls.Load()) + require.Equal(t, int32(http.StatusTemporaryRedirect), metricStatus.Load()) + require.Equal(t, http.MethodGet, metricMethod) + require.Equal(t, "nupf-ee", metricPath) + require.Nil(t, client.CheckRedirect) +} + +func TestCallAPIRedirectPolicyConcurrentUse(t *testing.T) { + client := &http.Client{ + Transport: clientTestRoundTripper(func(request *http.Request) (*http.Response, error) { + if request.URL.Host == "origin.example.com" { + return clientTestResponse(request, http.StatusTemporaryRedirect, http.Header{ + "Location": []string{"https://target.example.com/resource"}, + }), nil + } + return clientTestResponse(request, http.StatusNoContent, nil), nil + }), + Timeout: 5 * time.Second, + } + followConfiguration := &clientTestConfiguration{httpClient: client} + rejectConfiguration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{httpClient: client}, + policy: RejectRedirects, + } + + const iterations = 50 + errorsCh := make(chan error, iterations*2) + var waitGroup sync.WaitGroup + for range iterations { + waitGroup.Add(2) + go func() { + defer waitGroup.Done() + errorsCh <- callAPIExpectStatus(followConfiguration, http.StatusNoContent) + }() + go func() { + defer waitGroup.Done() + errorsCh <- callAPIExpectStatus(rejectConfiguration, http.StatusTemporaryRedirect) + }() + } + waitGroup.Wait() + close(errorsCh) + + for err := range errorsCh { + require.NoError(t, err) + } + require.Nil(t, client.CheckRedirect) +} + +func TestSelectHTTPClientRedirectPolicyConcurrentSharedUse(t *testing.T) { + followConfiguration := &clientTestConfiguration{} + rejectConfiguration := &redirectClientTestConfiguration{ + clientTestConfiguration: &clientTestConfiguration{}, + policy: RejectRedirects, + } + request, err := http.NewRequest(http.MethodGet, "https://origin.example.com/resource", nil) + require.NoError(t, err) + + const iterations = 100 + errorsCh := make(chan error, iterations*2) + var waitGroup sync.WaitGroup + for range iterations { + waitGroup.Add(2) + go func() { + defer waitGroup.Done() + selected, selectErr := selectHTTPClient(followConfiguration, request) + if selectErr != nil { + errorsCh <- selectErr + return + } + if selected != innerHTTP2Client || selected.CheckRedirect != nil { + errorsCh <- errors.New("follow selection changed shared HTTPS client") + return + } + errorsCh <- nil + }() + go func() { + defer waitGroup.Done() + selected, selectErr := selectHTTPClient(rejectConfiguration, request) + if selectErr != nil { + errorsCh <- selectErr + return + } + if selected == innerHTTP2Client { + errorsCh <- errors.New("reject selection returned shared HTTPS client") + return + } + if !errors.Is(selected.CheckRedirect(nil, nil), http.ErrUseLastResponse) { + errorsCh <- errors.New("reject selection did not install redirect policy") + return + } + errorsCh <- nil + }() + } + waitGroup.Wait() + close(errorsCh) + + for err := range errorsCh { + require.NoError(t, err) + } + require.Nil(t, innerHTTP2Client.CheckRedirect) +} + +func callAPIExpectStatus(configuration Configuration, expectedStatus int) error { + request, err := http.NewRequest(http.MethodGet, "https://origin.example.com/resource", nil) + if err != nil { + return err + } + response, err := CallAPI(configuration, request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != expectedStatus { + return fmt.Errorf("status = %d, want %d", response.StatusCode, expectedStatus) + } + return nil +} + +func clientTestResponse(request *http.Request, status int, header http.Header) *http.Response { + if header == nil { + header = make(http.Header) + } + return &http.Response{ + StatusCode: status, + Header: header, + Body: io.NopCloser(strings.NewReader("")), + Request: request, + } +} + +func requireSameHTTPClientSettings(t *testing.T, expected *http.Client, actual *http.Client) { + t.Helper() + require.Equal(t, reflect.ValueOf(expected.Transport).Pointer(), reflect.ValueOf(actual.Transport).Pointer()) + require.Equal(t, expected.Timeout, actual.Timeout) + if expected.Jar == nil { + require.Nil(t, actual.Jar) + } else { + require.Same(t, expected.Jar, actual.Jar) + } +} diff --git a/models/model_service_name.go b/models/model_service_name.go index 191ef4c4..4ac5a822 100644 --- a/models/model_service_name.go +++ b/models/model_service_name.go @@ -160,6 +160,7 @@ const ( ServiceName_NMBSTF_DISTSESSION ServiceName = "nmbstf-distsession" ServiceName_NPANF_PROSEKEY ServiceName = "npanf-prosekey" ServiceName_NPANF_USERID ServiceName = "npanf-userid" + ServiceName_NUPF_EE ServiceName = "nupf-ee" ServiceName_NUPF_OAM ServiceName = "nupf-oam" ServiceName_NUPF_CMI ServiceName = "nupf-cmi" ) diff --git a/models/model_smf_event.go b/models/model_smf_event.go index f3875fa1..b599b992 100644 --- a/models/model_smf_event.go +++ b/models/model_smf_event.go @@ -33,4 +33,5 @@ const ( SmfEvent_WLAN_INFO SmfEvent = "WLAN_INFO" SmfEvent_UPF_INFO SmfEvent = "UPF_INFO" SmfEvent_UP_STATUS_INFO SmfEvent = "UP_STATUS_INFO" + SmfEvent_UPF_EVENT SmfEvent = "UPF_EVENT" ) diff --git a/models/model_smf_event_exposure_event_subscription.go b/models/model_smf_event_exposure_event_subscription.go index e64936b3..dc7ad43f 100644 --- a/models/model_smf_event_exposure_event_subscription.go +++ b/models/model_smf_event_exposure_event_subscription.go @@ -23,6 +23,10 @@ type SmfEventExposureEventSubscription struct { // Indicates the subscription for UE transaction dispersion collectionon, if it is included and set to \"true\". Default value is \"false\". TransacDispInd bool `json:"transacDispInd,omitempty" yaml:"transacDispInd" bson:"transacDispInd,omitempty"` // Indicates Session Management Transaction metrics. - TransacMetrics []TransactionMetric `json:"transacMetrics,omitempty" yaml:"transacMetrics" bson:"transacMetrics,omitempty"` - UeIpAddr *IpAddr `json:"ueIpAddr,omitempty" yaml:"ueIpAddr" bson:"ueIpAddr,omitempty"` + TransacMetrics []TransactionMetric `json:"transacMetrics,omitempty" yaml:"transacMetrics" bson:"transacMetrics,omitempty"` + UeIpAddr *IpAddr `json:"ueIpAddr,omitempty" yaml:"ueIpAddr" bson:"ueIpAddr,omitempty"` + UpfEvents []UpfEvent `json:"upfEvents,omitempty" yaml:"upfEvents" bson:"upfEvents,omitempty"` + BundlingAllowed bool `json:"bundlingAllowed,omitempty" yaml:"bundlingAllowed" bson:"bundlingAllowed,omitempty"` + BundleId *uint32 `json:"bundleId,omitempty" yaml:"bundleId" bson:"bundleId,omitempty"` + BundledEventNotifyUri string `json:"bundledEventNotifyUri,omitempty" yaml:"bundledEventNotifyUri" bson:"bundledEventNotifyUri,omitempty"` } diff --git a/models/model_upf_create_event_subscription.go b/models/model_upf_create_event_subscription.go new file mode 100644 index 00000000..420ae406 --- /dev/null +++ b/models/model_upf_create_event_subscription.go @@ -0,0 +1,18 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package models + +// UpfCreateEventSubscription is the request wrapper for creating an UPF event subscription. +type UpfCreateEventSubscription struct { + Subscription UpfEventSubscription `json:"subscription" yaml:"subscription" bson:"subscription,omitempty"` + SupportedFeatures string `json:"supportedFeatures,omitempty" yaml:"supportedFeatures" bson:"supportedFeatures,omitempty"` +} diff --git a/models/model_upf_created_event_subscription.go b/models/model_upf_created_event_subscription.go new file mode 100644 index 00000000..313d06ba --- /dev/null +++ b/models/model_upf_created_event_subscription.go @@ -0,0 +1,19 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package models + +// UpfCreatedEventSubscription is returned after an UPF event subscription is created. +type UpfCreatedEventSubscription struct { + Subscription UpfEventSubscription `json:"subscription" yaml:"subscription" bson:"subscription,omitempty"` + SubscriptionId string `json:"subscriptionId" yaml:"subscriptionId" bson:"subscriptionId,omitempty"` + SupportedFeatures string `json:"supportedFeatures,omitempty" yaml:"supportedFeatures" bson:"supportedFeatures,omitempty"` +} diff --git a/models/model_upf_event.go b/models/model_upf_event.go new file mode 100644 index 00000000..a3601908 --- /dev/null +++ b/models/model_upf_event.go @@ -0,0 +1,19 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package models + +// UpfEvent represents the UPF event fields used by the SMF Event Exposure profile. +type UpfEvent struct { + Type UpfEventType `json:"type" yaml:"type" bson:"type,omitempty"` + MeasurementTypes []UpfMeasurementType `json:"measurementTypes,omitempty" yaml:"measurementTypes" bson:"measurementTypes,omitempty"` + GranularityOfMeasurement UpfGranularityOfMeasurement `json:"granularityOfMeasurement,omitempty" yaml:"granularityOfMeasurement" bson:"granularityOfMeasurement,omitempty"` +} diff --git a/models/model_upf_event_exposure_test.go b/models/model_upf_event_exposure_test.go new file mode 100644 index 00000000..4f472a89 --- /dev/null +++ b/models/model_upf_event_exposure_test.go @@ -0,0 +1,244 @@ +package models + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSmfUpfEventSubscriptionWireShape(t *testing.T) { + bundleId := uint32(0) + subscription := SmfEventExposureEventSubscription{ + Event: SmfEvent_UPF_EVENT, + UpfEvents: []UpfEvent{ + { + Type: UpfEventType_USER_DATA_USAGE_MEASURES, + MeasurementTypes: []UpfMeasurementType{UpfMeasurementType_VOLUME_MEASUREMENT}, + GranularityOfMeasurement: UpfGranularityOfMeasurement_PER_SESSION, + }, + }, + BundlingAllowed: true, + BundleId: &bundleId, + BundledEventNotifyUri: "https://nwdaf.example.com/upf-events", + } + + got, err := json.Marshal(subscription) + require.NoError(t, err) + require.JSONEq(t, `{ + "event":"UPF_EVENT", + "upfEvents":[{ + "type":"USER_DATA_USAGE_MEASURES", + "measurementTypes":["VOLUME_MEASUREMENT"], + "granularityOfMeasurement":"PER_SESSION" + }], + "bundlingAllowed":true, + "bundleId":0, + "bundledEventNotifyUri":"https://nwdaf.example.com/upf-events" + }`, string(got)) +} + +func TestUpfCreateEventSubscriptionWireShape(t *testing.T) { + bundleId := uint32(0) + request := UpfCreateEventSubscription{ + Subscription: UpfEventSubscription{ + EventList: []UpfEvent{ + { + Type: UpfEventType_USER_DATA_USAGE_MEASURES, + MeasurementTypes: []UpfMeasurementType{ + UpfMeasurementType_VOLUME_MEASUREMENT, + UpfMeasurementType_THROUGHPUT_MEASUREMENT, + }, + GranularityOfMeasurement: UpfGranularityOfMeasurement_PER_SESSION, + }, + }, + EventNotifyUri: "https://nwdaf.example.com/upf-events", + NotifyCorrelationId: "nsmf-notif-id", + EventReportingMode: UpfEventMode{ + Trigger: UpfEventTrigger_PERIODIC, + RepPeriod: 10, + }, + NfId: "11111111-2222-3333-4444-555555555555", + UeIpAddress: &IpAddr{Ipv4Addr: "192.0.2.1"}, + Supi: "imsi-001010000000001", + Dnn: "internet", + Snssai: &Snssai{Sst: 1, Sd: "010203"}, + BundlingAllowed: true, + BundleId: &bundleId, + BundledEventNotifyUri: "https://nwdaf.example.com/bundled-upf-events", + }, + SupportedFeatures: "1", + } + + got, err := json.Marshal(request) + require.NoError(t, err) + require.JSONEq(t, `{ + "subscription":{ + "eventList":[{ + "type":"USER_DATA_USAGE_MEASURES", + "measurementTypes":["VOLUME_MEASUREMENT","THROUGHPUT_MEASUREMENT"], + "granularityOfMeasurement":"PER_SESSION" + }], + "eventNotifyUri":"https://nwdaf.example.com/upf-events", + "notifyCorrelationId":"nsmf-notif-id", + "eventReportingMode":{"trigger":"PERIODIC","repPeriod":10}, + "nfId":"11111111-2222-3333-4444-555555555555", + "ueIpAddress":{"ipv4Addr":"192.0.2.1"}, + "supi":"imsi-001010000000001", + "dnn":"internet", + "snssai":{"sst":1,"sd":"010203"}, + "bundlingAllowed":true, + "bundleId":0, + "bundledEventNotifyUri":"https://nwdaf.example.com/bundled-upf-events" + }, + "supportedFeatures":"1" + }`, string(got)) +} + +func TestUpfEventSubscriptionAnyUeWireShape(t *testing.T) { + subscription := UpfEventSubscription{ + EventList: []UpfEvent{{Type: UpfEventType_USER_DATA_USAGE_MEASURES}}, + EventNotifyUri: "https://nwdaf.example.com/upf-events", + NotifyCorrelationId: "nsmf-notif-id", + EventReportingMode: UpfEventMode{Trigger: UpfEventTrigger_PERIODIC}, + NfId: "11111111-2222-3333-4444-555555555555", + AnyUe: true, + } + + got, err := json.Marshal(subscription) + require.NoError(t, err) + require.Contains(t, string(got), `"anyUe":true`) +} + +func TestBundleIdUint32UpperBoundWireShape(t *testing.T) { + bundleId := uint32(4294967295) + tests := []struct { + name string + value interface{} + }{ + { + name: "Nsmf", + value: SmfEventExposureEventSubscription{ + Event: SmfEvent_UPF_EVENT, + BundleId: &bundleId, + }, + }, + { + name: "Nupf", + value: UpfEventSubscription{ + EventList: []UpfEvent{{Type: UpfEventType_USER_DATA_USAGE_MEASURES}}, + EventNotifyUri: "https://nwdaf.example.com/upf-events", + NotifyCorrelationId: "nsmf-notif-id", + EventReportingMode: UpfEventMode{Trigger: UpfEventTrigger_PERIODIC}, + NfId: "11111111-2222-3333-4444-555555555555", + BundleId: &bundleId, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := json.Marshal(test.value) + require.NoError(t, err) + require.Contains(t, string(got), `"bundleId":4294967295`) + }) + } +} + +func TestBundleIdRejectsValuesOutsideUint32Range(t *testing.T) { + tests := []struct { + name string + payload string + newTarget func() interface{} + }{ + { + name: "Nsmf negative", + payload: `{"event":"UPF_EVENT","bundleId":-1}`, + newTarget: func() interface{} { return &SmfEventExposureEventSubscription{} }, + }, + { + name: "Nsmf above maximum", + payload: `{"event":"UPF_EVENT","bundleId":4294967296}`, + newTarget: func() interface{} { return &SmfEventExposureEventSubscription{} }, + }, + { + name: "Nupf negative", + payload: `{"bundleId":-1}`, + newTarget: func() interface{} { return &UpfEventSubscription{} }, + }, + { + name: "Nupf above maximum", + payload: `{"bundleId":4294967296}`, + newTarget: func() interface{} { return &UpfEventSubscription{} }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := json.Unmarshal([]byte(test.payload), test.newTarget()) + require.Error(t, err) + }) + } +} + +func TestBundlingAllowedFalseIsOmitted(t *testing.T) { + tests := []struct { + name string + value interface{} + }{ + { + name: "Nsmf", + value: SmfEventExposureEventSubscription{Event: SmfEvent_UPF_EVENT}, + }, + { + name: "Nupf", + value: UpfEventSubscription{ + EventList: []UpfEvent{{Type: UpfEventType_USER_DATA_USAGE_MEASURES}}, + EventNotifyUri: "https://nwdaf.example.com/upf-events", + EventReportingMode: UpfEventMode{Trigger: UpfEventTrigger_PERIODIC}, + NfId: "11111111-2222-3333-4444-555555555555", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := json.Marshal(test.value) + require.NoError(t, err) + require.NotContains(t, string(got), "bundlingAllowed") + }) + } +} + +func TestUpfEventExposureEnumValues(t *testing.T) { + tests := []struct { + name string + value interface{} + want string + }{ + {"event QOS_MONITORING", UpfEventType_QOS_MONITORING, "QOS_MONITORING"}, + {"event USER_DATA_USAGE_MEASURES", UpfEventType_USER_DATA_USAGE_MEASURES, "USER_DATA_USAGE_MEASURES"}, + {"event USER_DATA_USAGE_TRENDS", UpfEventType_USER_DATA_USAGE_TRENDS, "USER_DATA_USAGE_TRENDS"}, + {"event TSC_MNGT_INFO", UpfEventType_TSC_MNGT_INFO, "TSC_MNGT_INFO"}, + {"event UE_NAT_MAPPING_INFO", UpfEventType_UE_NAT_MAPPING_INFO, "UE_NAT_MAPPING_INFO"}, + {"event HANDLING_OF_PAYLOAD_HEADERS_INFO", UpfEventType_HANDLING_OF_PAYLOAD_HEADERS_INFO, "HANDLING_OF_PAYLOAD_HEADERS_INFO"}, + {"event SUBSCRIPTION_TERMINATION", UpfEventType_SUBSCRIPTION_TERMINATION, "SUBSCRIPTION_TERMINATION"}, + {"measurement VOLUME_MEASUREMENT", UpfMeasurementType_VOLUME_MEASUREMENT, "VOLUME_MEASUREMENT"}, + {"measurement THROUGHPUT_MEASUREMENT", UpfMeasurementType_THROUGHPUT_MEASUREMENT, "THROUGHPUT_MEASUREMENT"}, + {"measurement APPLICATION_RELATED_INFO", UpfMeasurementType_APPLICATION_RELATED_INFO, "APPLICATION_RELATED_INFO"}, + {"granularity PER_APPLICATION", UpfGranularityOfMeasurement_PER_APPLICATION, "PER_APPLICATION"}, + {"granularity PER_SESSION", UpfGranularityOfMeasurement_PER_SESSION, "PER_SESSION"}, + {"granularity PER_FLOW", UpfGranularityOfMeasurement_PER_FLOW, "PER_FLOW"}, + {"trigger ONE_TIME", UpfEventTrigger_ONE_TIME, "ONE_TIME"}, + {"trigger PERIODIC", UpfEventTrigger_PERIODIC, "PERIODIC"}, + {"trigger CONTINUOUS", UpfEventTrigger_CONTINUOUS, "CONTINUOUS"}, + {"service NUPF_EE", ServiceName_NUPF_EE, "nupf-ee"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, fmt.Sprint(test.value)) + }) + } +} diff --git a/models/model_upf_event_mode.go b/models/model_upf_event_mode.go new file mode 100644 index 00000000..97450342 --- /dev/null +++ b/models/model_upf_event_mode.go @@ -0,0 +1,18 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package models + +// UpfEventMode represents the reporting controls used by the SMF Event Exposure profile. +type UpfEventMode struct { + Trigger UpfEventTrigger `json:"trigger" yaml:"trigger" bson:"trigger,omitempty"` + RepPeriod int32 `json:"repPeriod,omitempty" yaml:"repPeriod" bson:"repPeriod,omitempty"` +} diff --git a/models/model_upf_event_subscription.go b/models/model_upf_event_subscription.go new file mode 100644 index 00000000..8399e476 --- /dev/null +++ b/models/model_upf_event_subscription.go @@ -0,0 +1,29 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package models + +// UpfEventSubscription represents an UPF Event Exposure subscription. +type UpfEventSubscription struct { + EventList []UpfEvent `json:"eventList" yaml:"eventList" bson:"eventList,omitempty"` + EventNotifyUri string `json:"eventNotifyUri" yaml:"eventNotifyUri" bson:"eventNotifyUri,omitempty"` + NotifyCorrelationId string `json:"notifyCorrelationId" yaml:"notifyCorrelationId" bson:"notifyCorrelationId,omitempty"` + EventReportingMode UpfEventMode `json:"eventReportingMode" yaml:"eventReportingMode" bson:"eventReportingMode,omitempty"` + NfId string `json:"nfId" yaml:"nfId" bson:"nfId,omitempty"` + UeIpAddress *IpAddr `json:"ueIpAddress,omitempty" yaml:"ueIpAddress" bson:"ueIpAddress,omitempty"` + AnyUe bool `json:"anyUe,omitempty" yaml:"anyUe" bson:"anyUe,omitempty"` + Supi string `json:"supi,omitempty" yaml:"supi" bson:"supi,omitempty"` + Dnn string `json:"dnn,omitempty" yaml:"dnn" bson:"dnn,omitempty"` + Snssai *Snssai `json:"snssai,omitempty" yaml:"snssai" bson:"snssai,omitempty"` + BundlingAllowed bool `json:"bundlingAllowed,omitempty" yaml:"bundlingAllowed" bson:"bundlingAllowed,omitempty"` + BundleId *uint32 `json:"bundleId,omitempty" yaml:"bundleId" bson:"bundleId,omitempty"` + BundledEventNotifyUri string `json:"bundledEventNotifyUri,omitempty" yaml:"bundledEventNotifyUri" bson:"bundledEventNotifyUri,omitempty"` +} diff --git a/models/model_upf_event_trigger.go b/models/model_upf_event_trigger.go new file mode 100644 index 00000000..75fbf3d4 --- /dev/null +++ b/models/model_upf_event_trigger.go @@ -0,0 +1,21 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package models + +type UpfEventTrigger string + +// List of UpfEventTrigger +const ( + UpfEventTrigger_ONE_TIME UpfEventTrigger = "ONE_TIME" + UpfEventTrigger_PERIODIC UpfEventTrigger = "PERIODIC" + UpfEventTrigger_CONTINUOUS UpfEventTrigger = "CONTINUOUS" +) diff --git a/models/model_upf_event_type.go b/models/model_upf_event_type.go new file mode 100644 index 00000000..8f1df7db --- /dev/null +++ b/models/model_upf_event_type.go @@ -0,0 +1,25 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package models + +type UpfEventType string + +// List of UpfEventType +const ( + UpfEventType_QOS_MONITORING UpfEventType = "QOS_MONITORING" + UpfEventType_USER_DATA_USAGE_MEASURES UpfEventType = "USER_DATA_USAGE_MEASURES" + UpfEventType_USER_DATA_USAGE_TRENDS UpfEventType = "USER_DATA_USAGE_TRENDS" + UpfEventType_TSC_MNGT_INFO UpfEventType = "TSC_MNGT_INFO" + UpfEventType_UE_NAT_MAPPING_INFO UpfEventType = "UE_NAT_MAPPING_INFO" + UpfEventType_HANDLING_OF_PAYLOAD_HEADERS_INFO UpfEventType = "HANDLING_OF_PAYLOAD_HEADERS_INFO" + UpfEventType_SUBSCRIPTION_TERMINATION UpfEventType = "SUBSCRIPTION_TERMINATION" +) diff --git a/models/model_upf_granularity_of_measurement.go b/models/model_upf_granularity_of_measurement.go new file mode 100644 index 00000000..d1d87aaf --- /dev/null +++ b/models/model_upf_granularity_of_measurement.go @@ -0,0 +1,21 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package models + +type UpfGranularityOfMeasurement string + +// List of UpfGranularityOfMeasurement +const ( + UpfGranularityOfMeasurement_PER_APPLICATION UpfGranularityOfMeasurement = "PER_APPLICATION" + UpfGranularityOfMeasurement_PER_SESSION UpfGranularityOfMeasurement = "PER_SESSION" + UpfGranularityOfMeasurement_PER_FLOW UpfGranularityOfMeasurement = "PER_FLOW" +) diff --git a/models/model_upf_measurement_type.go b/models/model_upf_measurement_type.go new file mode 100644 index 00000000..ab2c8827 --- /dev/null +++ b/models/model_upf_measurement_type.go @@ -0,0 +1,21 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package models + +type UpfMeasurementType string + +// List of UpfMeasurementType +const ( + UpfMeasurementType_VOLUME_MEASUREMENT UpfMeasurementType = "VOLUME_MEASUREMENT" + UpfMeasurementType_THROUGHPUT_MEASUREMENT UpfMeasurementType = "THROUGHPUT_MEASUREMENT" + UpfMeasurementType_APPLICATION_RELATED_INFO UpfMeasurementType = "APPLICATION_RELATED_INFO" +) diff --git a/upf/EventExposure/api_individual_subscription_document.go b/upf/EventExposure/api_individual_subscription_document.go new file mode 100644 index 00000000..cfbb7154 --- /dev/null +++ b/upf/EventExposure/api_individual_subscription_document.go @@ -0,0 +1,110 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package EventExposure + +import ( + "context" + "io/ioutil" + "net/url" + "strings" + + "github.com/free5gc/openapi" + "github.com/free5gc/openapi/models" +) + +type IndividualSubscriptionDocumentApiService service + +type DeleteSubscriptionRequest struct { + SubscriptionId *string +} + +func (r *DeleteSubscriptionRequest) SetSubscriptionId(subscriptionId string) { + r.SubscriptionId = &subscriptionId +} + +type DeleteSubscriptionResponse struct{} + +type DeleteSubscriptionError struct { + Location string + Var3gppSbiTargetNfId string + ProblemDetails models.ProblemDetails + RedirectResponse models.RedirectResponse +} + +// DeleteSubscription deletes an individual UPF Event Exposure subscription. +func (a *IndividualSubscriptionDocumentApiService) DeleteSubscription(ctx context.Context, request *DeleteSubscriptionRequest) (*DeleteSubscriptionResponse, error) { + var ( + localVarHTTPMethod = strings.ToUpper("Delete") + localVarPostBody interface{} + localVarFormFileName string + localVarFileName string + localVarFileBytes []byte + localVarReturnValue DeleteSubscriptionResponse + ) + + localVarPath := a.client.cfg.BasePath() + "/ee-subscriptions/{subscriptionId}" + localVarPath = strings.Replace(localVarPath, "{"+"subscriptionId"+"}", openapi.StringOfValue(*request.SubscriptionId), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + localVarHeaderParams["Content-Type"] = "application/json" + localVarHeaderParams["Accept"] = "application/json, application/problem+json" + + r, err := openapi.PrepareRequest(ctx, a.client.cfg, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := openapi.CallAPI(a.client.cfg, r) + if err != nil || localVarHTTPResponse == nil { + return nil, err + } + + localVarBody, err := ioutil.ReadAll(localVarHTTPResponse.Body) + if err != nil { + return nil, err + } + if err = localVarHTTPResponse.Body.Close(); err != nil { + return nil, err + } + + apiError := openapi.GenericOpenAPIError{ + RawBody: localVarBody, + ErrorStatus: localVarHTTPResponse.StatusCode, + } + + switch localVarHTTPResponse.StatusCode { + case 204: + return &localVarReturnValue, nil + case 307, 308: + var v DeleteSubscriptionError + err = openapi.Deserialize(&v.RedirectResponse, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + return nil, err + } + v.Location = localVarHTTPResponse.Header.Get("Location") + v.Var3gppSbiTargetNfId = localVarHTTPResponse.Header.Get("3gpp-Sbi-Target-Nf-Id") + apiError.ErrorModel = v + return nil, apiError + case 400, 401, 403, 404, 411, 413, 415, 429, 500, 502, 503: + var v DeleteSubscriptionError + err = openapi.Deserialize(&v.ProblemDetails, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + return nil, err + } + apiError.ErrorModel = v + return nil, apiError + default: + return nil, apiError + } +} diff --git a/upf/EventExposure/api_individual_subscription_document_test.go b/upf/EventExposure/api_individual_subscription_document_test.go new file mode 100644 index 00000000..157293bf --- /dev/null +++ b/upf/EventExposure/api_individual_subscription_document_test.go @@ -0,0 +1,112 @@ +package EventExposure + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/free5gc/openapi" + "github.com/stretchr/testify/require" +) + +func TestDeleteSubscriptionStatusHandling(t *testing.T) { + type responseKind string + const ( + successResponse responseKind = "success" + problemResponse responseKind = "problem" + redirectResponse responseKind = "redirect" + defaultResponse responseKind = "default" + ) + type testCase struct { + status int + kind responseKind + body string + headers http.Header + } + + tests := []testCase{ + { + status: http.StatusNoContent, + kind: successResponse, + headers: http.Header{}, + }, + } + for _, status := range []int{400, 401, 403, 404, 411, 413, 415, 429, 500, 502, 503} { + tests = append(tests, testCase{ + status: status, + kind: problemResponse, + body: fmt.Sprintf(`{"status":%d,"cause":"TEST_PROBLEM"}`, status), + headers: http.Header{ + "Content-Type": []string{"application/problem+json"}, + }, + }) + } + for _, status := range []int{307, 308} { + tests = append(tests, testCase{ + status: status, + kind: redirectResponse, + body: `{"cause":"TEMPORARY_REDIRECTION"}`, + headers: http.Header{ + "Content-Type": []string{"application/json"}, + "Location": []string{"https://redirect.example.com/nupf-ee/v1/ee-subscriptions/sub-1"}, + "3gpp-Sbi-Target-Nf-Id": []string{"target-nf-id"}, + }, + }) + } + tests = append(tests, testCase{ + status: http.StatusTeapot, + kind: defaultResponse, + body: "unexpected response", + headers: http.Header{"Content-Type": []string{"text/plain"}}, + }) + + for _, test := range tests { + t.Run(fmt.Sprintf("status_%d", test.status), func(t *testing.T) { + var policy openapi.RedirectPolicy + if test.kind == redirectResponse { + policy = openapi.RejectRedirects + } + client, captured := newTestAPIClient(test.status, test.body, test.headers, policy) + request := &DeleteSubscriptionRequest{} + request.SetSubscriptionId("sub-1") + + response, err := client.IndividualSubscriptionDocumentApi.DeleteSubscription(context.Background(), request) + + require.Equal(t, http.MethodDelete, captured.method) + require.Equal(t, "/nupf-ee/v1/ee-subscriptions/sub-1", captured.path) + + if test.kind == successResponse { + require.NoError(t, err) + require.NotNil(t, response) + return + } + + require.Nil(t, response) + require.Error(t, err) + var apiError openapi.GenericOpenAPIError + require.ErrorAs(t, err, &apiError) + require.Equal(t, test.status, apiError.ErrorStatus) + + switch test.kind { + case problemResponse: + model, ok := apiError.Model().(DeleteSubscriptionError) + require.True(t, ok) + require.Equal(t, int32(test.status), model.ProblemDetails.Status) + require.Equal(t, "TEST_PROBLEM", model.ProblemDetails.Cause) + case redirectResponse: + model, ok := apiError.Model().(DeleteSubscriptionError) + require.True(t, ok) + require.Equal(t, "TEMPORARY_REDIRECTION", model.RedirectResponse.Cause) + require.Equal(t, "https://redirect.example.com/nupf-ee/v1/ee-subscriptions/sub-1", model.Location) + require.Equal(t, "target-nf-id", model.Var3gppSbiTargetNfId) + require.Equal(t, 1, captured.requestCount) + require.Equal(t, 1, captured.originCount) + require.Zero(t, captured.redirectCount) + require.Zero(t, captured.requestBodyCount) + case defaultResponse: + require.Nil(t, apiError.Model()) + } + }) + } +} diff --git a/upf/EventExposure/api_subscriptions_collection.go b/upf/EventExposure/api_subscriptions_collection.go new file mode 100644 index 00000000..f81518cb --- /dev/null +++ b/upf/EventExposure/api_subscriptions_collection.go @@ -0,0 +1,119 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package EventExposure + +import ( + "context" + "io/ioutil" + "net/url" + "strings" + + "github.com/free5gc/openapi" + "github.com/free5gc/openapi/models" +) + +type SubscriptionsCollectionApiService service + +// CreateSubscriptionRequest contains the Nupf Event Exposure create request body. +type CreateSubscriptionRequest struct { + UpfCreateEventSubscription *models.UpfCreateEventSubscription +} + +func (r *CreateSubscriptionRequest) SetUpfCreateEventSubscription(subscription models.UpfCreateEventSubscription) { + r.UpfCreateEventSubscription = &subscription +} + +type CreateSubscriptionResponse struct { + Location string + UpfCreatedEventSubscription models.UpfCreatedEventSubscription +} + +type CreateSubscriptionError struct { + Location string + Var3gppSbiTargetNfId string + ProblemDetails models.ProblemDetails + RedirectResponse models.RedirectResponse +} + +// CreateSubscription creates an individual UPF Event Exposure subscription. +func (a *SubscriptionsCollectionApiService) CreateSubscription(ctx context.Context, request *CreateSubscriptionRequest) (*CreateSubscriptionResponse, error) { + var ( + localVarHTTPMethod = strings.ToUpper("Post") + localVarPostBody interface{} + localVarFormFileName string + localVarFileName string + localVarFileBytes []byte + localVarReturnValue CreateSubscriptionResponse + ) + + localVarPath := a.client.cfg.BasePath() + "/ee-subscriptions" + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + localVarHeaderParams["Content-Type"] = "application/json" + localVarHeaderParams["Accept"] = "application/json, application/problem+json" + localVarPostBody = request.UpfCreateEventSubscription + + r, err := openapi.PrepareRequest(ctx, a.client.cfg, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := openapi.CallAPI(a.client.cfg, r) + if err != nil || localVarHTTPResponse == nil { + return nil, err + } + + localVarBody, err := ioutil.ReadAll(localVarHTTPResponse.Body) + if err != nil { + return nil, err + } + if err = localVarHTTPResponse.Body.Close(); err != nil { + return nil, err + } + + apiError := openapi.GenericOpenAPIError{ + RawBody: localVarBody, + ErrorStatus: localVarHTTPResponse.StatusCode, + } + + switch localVarHTTPResponse.StatusCode { + case 201: + err = openapi.Deserialize(&localVarReturnValue.UpfCreatedEventSubscription, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + return nil, err + } + localVarReturnValue.Location = localVarHTTPResponse.Header.Get("Location") + return &localVarReturnValue, nil + case 307, 308: + var v CreateSubscriptionError + err = openapi.Deserialize(&v.RedirectResponse, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + return nil, err + } + v.Location = localVarHTTPResponse.Header.Get("Location") + v.Var3gppSbiTargetNfId = localVarHTTPResponse.Header.Get("3gpp-Sbi-Target-Nf-Id") + apiError.ErrorModel = v + return nil, apiError + case 400, 401, 403, 404, 411, 413, 415, 429, 500, 501, 502, 503: + var v CreateSubscriptionError + err = openapi.Deserialize(&v.ProblemDetails, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + return nil, err + } + apiError.ErrorModel = v + return nil, apiError + default: + return nil, apiError + } +} diff --git a/upf/EventExposure/api_subscriptions_collection_test.go b/upf/EventExposure/api_subscriptions_collection_test.go new file mode 100644 index 00000000..7c1b3291 --- /dev/null +++ b/upf/EventExposure/api_subscriptions_collection_test.go @@ -0,0 +1,323 @@ +package EventExposure + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/free5gc/openapi" + "github.com/free5gc/openapi/models" + "github.com/stretchr/testify/require" +) + +type testRoundTripper func(*http.Request) (*http.Response, error) + +func (f testRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +type capturedRequest struct { + method string + path string + body []byte + requestCount int + originCount int + redirectCount int + requestBodyCount int +} + +func newTestAPIClient(status int, body string, headers http.Header, policy openapi.RedirectPolicy) (*APIClient, *capturedRequest) { + captured := &capturedRequest{} + httpClient := &http.Client{ + Transport: testRoundTripper(func(request *http.Request) (*http.Response, error) { + captured.requestCount++ + switch request.URL.Host { + case "upf.example.com": + captured.originCount++ + case "redirect.example.com": + captured.redirectCount++ + } + captured.method = request.Method + captured.path = request.URL.Path + if request.Body != nil { + captured.requestBodyCount++ + requestBody, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + captured.body = requestBody + } + + responseHeaders := make(http.Header) + for key, values := range headers { + responseHeaders[key] = append([]string(nil), values...) + } + return &http.Response{ + StatusCode: status, + Header: responseHeaders, + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil + }), + } + + configuration := NewConfiguration() + configuration.SetBasePath("https://upf.example.com") + configuration.SetHTTPClient(httpClient) + configuration.SetRedirectPolicy(policy) + return NewAPIClient(configuration), captured +} + +func TestCreateSubscriptionStatusHandling(t *testing.T) { + type responseKind string + const ( + successResponse responseKind = "success" + problemResponse responseKind = "problem" + redirectResponse responseKind = "redirect" + defaultResponse responseKind = "default" + ) + type testCase struct { + status int + kind responseKind + body string + headers http.Header + } + + tests := []testCase{ + { + status: http.StatusCreated, + kind: successResponse, + body: `{ + "subscription":{ + "eventList":[{ + "type":"USER_DATA_USAGE_MEASURES", + "measurementTypes":["VOLUME_MEASUREMENT"], + "granularityOfMeasurement":"PER_SESSION" + }], + "eventNotifyUri":"https://nwdaf.example.com/upf-events", + "notifyCorrelationId":"nsmf-notif-id", + "eventReportingMode":{"trigger":"PERIODIC","repPeriod":10}, + "nfId":"11111111-2222-3333-4444-555555555555", + "ueIpAddress":{"ipv4Addr":"192.0.2.1"} + }, + "subscriptionId":"sub-1", + "supportedFeatures":"1" + }`, + headers: http.Header{ + "Content-Type": []string{"application/json"}, + "Location": []string{"https://upf.example.com/nupf-ee/v1/ee-subscriptions/sub-1"}, + }, + }, + } + + for _, status := range []int{400, 401, 403, 404, 411, 413, 415, 429, 500, 501, 502, 503} { + tests = append(tests, testCase{ + status: status, + kind: problemResponse, + body: fmt.Sprintf(`{"status":%d,"cause":"TEST_PROBLEM"}`, status), + headers: http.Header{ + "Content-Type": []string{"application/problem+json"}, + }, + }) + } + for _, status := range []int{307, 308} { + tests = append(tests, testCase{ + status: status, + kind: redirectResponse, + body: `{"cause":"TEMPORARY_REDIRECTION"}`, + headers: http.Header{ + "Content-Type": []string{"application/json"}, + "Location": []string{"https://redirect.example.com/nupf-ee/v1/ee-subscriptions"}, + "3gpp-Sbi-Target-Nf-Id": []string{"target-nf-id"}, + }, + }) + } + tests = append(tests, testCase{ + status: http.StatusTeapot, + kind: defaultResponse, + body: "unexpected response", + headers: http.Header{"Content-Type": []string{"text/plain"}}, + }) + + for _, test := range tests { + t.Run(fmt.Sprintf("status_%d", test.status), func(t *testing.T) { + var policy openapi.RedirectPolicy + if test.kind == redirectResponse { + policy = openapi.RejectRedirects + } + client, captured := newTestAPIClient(test.status, test.body, test.headers, policy) + request := &CreateSubscriptionRequest{} + request.SetUpfCreateEventSubscription(testCreateEventSubscription()) + + response, err := client.SubscriptionsCollectionApi.CreateSubscription(context.Background(), request) + + require.Equal(t, http.MethodPost, captured.method) + require.Equal(t, "/nupf-ee/v1/ee-subscriptions", captured.path) + require.JSONEq(t, `{ + "subscription":{ + "eventList":[{ + "type":"USER_DATA_USAGE_MEASURES", + "measurementTypes":["VOLUME_MEASUREMENT"], + "granularityOfMeasurement":"PER_SESSION" + }], + "eventNotifyUri":"https://nwdaf.example.com/upf-events", + "notifyCorrelationId":"nsmf-notif-id", + "eventReportingMode":{"trigger":"PERIODIC","repPeriod":10}, + "nfId":"11111111-2222-3333-4444-555555555555", + "ueIpAddress":{"ipv4Addr":"192.0.2.1"} + } + }`, string(captured.body)) + + if test.kind == successResponse { + require.NoError(t, err) + require.Equal(t, "https://upf.example.com/nupf-ee/v1/ee-subscriptions/sub-1", response.Location) + created := response.UpfCreatedEventSubscription + require.Equal(t, "sub-1", created.SubscriptionId) + require.Equal(t, "1", created.SupportedFeatures) + require.Equal(t, "https://nwdaf.example.com/upf-events", created.Subscription.EventNotifyUri) + require.Equal(t, "nsmf-notif-id", created.Subscription.NotifyCorrelationId) + require.Equal(t, models.UpfEventTrigger_PERIODIC, created.Subscription.EventReportingMode.Trigger) + require.Equal(t, int32(10), created.Subscription.EventReportingMode.RepPeriod) + require.Equal(t, "11111111-2222-3333-4444-555555555555", created.Subscription.NfId) + require.Equal(t, &models.IpAddr{Ipv4Addr: "192.0.2.1"}, created.Subscription.UeIpAddress) + require.Len(t, created.Subscription.EventList, 1) + require.Equal(t, models.UpfEventType_USER_DATA_USAGE_MEASURES, created.Subscription.EventList[0].Type) + require.Equal(t, []models.UpfMeasurementType{models.UpfMeasurementType_VOLUME_MEASUREMENT}, created.Subscription.EventList[0].MeasurementTypes) + require.Equal(t, models.UpfGranularityOfMeasurement_PER_SESSION, created.Subscription.EventList[0].GranularityOfMeasurement) + return + } + + require.Nil(t, response) + require.Error(t, err) + var apiError openapi.GenericOpenAPIError + require.ErrorAs(t, err, &apiError) + require.Equal(t, test.status, apiError.ErrorStatus) + + switch test.kind { + case problemResponse: + model, ok := apiError.Model().(CreateSubscriptionError) + require.True(t, ok) + require.Equal(t, int32(test.status), model.ProblemDetails.Status) + require.Equal(t, "TEST_PROBLEM", model.ProblemDetails.Cause) + case redirectResponse: + model, ok := apiError.Model().(CreateSubscriptionError) + require.True(t, ok) + require.Equal(t, "TEMPORARY_REDIRECTION", model.RedirectResponse.Cause) + require.Equal(t, "https://redirect.example.com/nupf-ee/v1/ee-subscriptions", model.Location) + require.Equal(t, "target-nf-id", model.Var3gppSbiTargetNfId) + require.Equal(t, 1, captured.requestCount) + require.Equal(t, 1, captured.originCount) + require.Zero(t, captured.redirectCount) + require.Equal(t, 1, captured.requestBodyCount) + case defaultResponse: + require.Nil(t, apiError.Model()) + } + }) + } +} + +func TestCreateSubscriptionFollowsRedirectWithReplayableBody(t *testing.T) { + type observedRequest struct { + method string + host string + path string + body []byte + } + + for _, redirectStatus := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(fmt.Sprintf("status_%d", redirectStatus), func(t *testing.T) { + var observed []observedRequest + httpClient := &http.Client{ + Transport: testRoundTripper(func(request *http.Request) (*http.Response, error) { + requestBody, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + observed = append(observed, observedRequest{ + method: request.Method, + host: request.URL.Host, + path: request.URL.Path, + body: requestBody, + }) + + if len(observed) == 1 { + return &http.Response{ + StatusCode: redirectStatus, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Location": []string{"https://redirect.example.com/nupf-ee/v1/ee-subscriptions"}, + }, + Body: io.NopCloser(strings.NewReader(`{"cause":"TEMPORARY_REDIRECTION"}`)), + Request: request, + }, nil + } + + return &http.Response{ + StatusCode: http.StatusCreated, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Location": []string{"https://redirect.example.com/nupf-ee/v1/ee-subscriptions/sub-1"}, + }, + Body: io.NopCloser(strings.NewReader(`{ + "subscription":{ + "eventList":[{"type":"USER_DATA_USAGE_MEASURES"}], + "eventNotifyUri":"https://nwdaf.example.com/upf-events", + "notifyCorrelationId":"nsmf-notif-id", + "eventReportingMode":{"trigger":"PERIODIC"}, + "nfId":"11111111-2222-3333-4444-555555555555" + }, + "subscriptionId":"sub-1" + }`)), + Request: request, + }, nil + }), + } + + configuration := NewConfiguration() + configuration.SetBasePath("https://upf.example.com") + configuration.SetHTTPClient(httpClient) + client := NewAPIClient(configuration) + request := &CreateSubscriptionRequest{} + request.SetUpfCreateEventSubscription(testCreateEventSubscription()) + + response, err := client.SubscriptionsCollectionApi.CreateSubscription(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, "sub-1", response.UpfCreatedEventSubscription.SubscriptionId) + require.Equal(t, "https://redirect.example.com/nupf-ee/v1/ee-subscriptions/sub-1", response.Location) + require.Len(t, observed, 2) + require.Equal(t, http.MethodPost, observed[0].method) + require.Equal(t, http.MethodPost, observed[1].method) + require.Equal(t, "upf.example.com", observed[0].host) + require.Equal(t, "redirect.example.com", observed[1].host) + require.Equal(t, "/nupf-ee/v1/ee-subscriptions", observed[0].path) + require.Equal(t, observed[0].path, observed[1].path) + require.JSONEq(t, string(observed[0].body), string(observed[1].body)) + }) + } +} + +func testCreateEventSubscription() models.UpfCreateEventSubscription { + return models.UpfCreateEventSubscription{ + Subscription: models.UpfEventSubscription{ + EventList: []models.UpfEvent{ + { + Type: models.UpfEventType_USER_DATA_USAGE_MEASURES, + MeasurementTypes: []models.UpfMeasurementType{models.UpfMeasurementType_VOLUME_MEASUREMENT}, + GranularityOfMeasurement: models.UpfGranularityOfMeasurement_PER_SESSION, + }, + }, + EventNotifyUri: "https://nwdaf.example.com/upf-events", + NotifyCorrelationId: "nsmf-notif-id", + EventReportingMode: models.UpfEventMode{ + Trigger: models.UpfEventTrigger_PERIODIC, + RepPeriod: 10, + }, + NfId: "11111111-2222-3333-4444-555555555555", + UeIpAddress: &models.IpAddr{Ipv4Addr: "192.0.2.1"}, + }, + } +} diff --git a/upf/EventExposure/client.go b/upf/EventExposure/client.go new file mode 100644 index 00000000..e1f78ea4 --- /dev/null +++ b/upf/EventExposure/client.go @@ -0,0 +1,37 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package EventExposure + +// APIClient manages communication with the Nupf_EventExposure API v1.2.0. +type APIClient struct { + cfg *Configuration + common service + + IndividualSubscriptionDocumentApi *IndividualSubscriptionDocumentApiService + SubscriptionsCollectionApi *SubscriptionsCollectionApiService +} + +type service struct { + client *APIClient +} + +// NewAPIClient creates a new API client. +func NewAPIClient(cfg *Configuration) *APIClient { + c := &APIClient{} + c.cfg = cfg + c.common.client = c + + c.IndividualSubscriptionDocumentApi = (*IndividualSubscriptionDocumentApiService)(&c.common) + c.SubscriptionsCollectionApi = (*SubscriptionsCollectionApiService)(&c.common) + + return c +} diff --git a/upf/EventExposure/configuration.go b/upf/EventExposure/configuration.go new file mode 100644 index 00000000..0f58d6cd --- /dev/null +++ b/upf/EventExposure/configuration.go @@ -0,0 +1,96 @@ +/* + * Nupf_EventExposure + * + * UPF Event Exposure Service. Copyright 2025, 3GPP Organizational Partners (ARIB, ATIS, CCSA, ETSI, TSDSI, TTA, TTC). All rights reserved. + * + * Source file: 3GPP TS 29.564 V19.5.0; 5G System; User Plane Function Services; Stage 3. + * Url: https://www.3gpp.org/ftp/Specs/archive/29_series/29.564/ + * + * API version: 1.2.0 + */ + +package EventExposure + +import ( + "net/http" + "strings" + + "github.com/free5gc/openapi" +) + +type Configuration struct { + url string + basePath string + host string + defaultHeader map[string]string + userAgent string + httpClient *http.Client + MetricsHook openapi.RequestMetricsHook + redirectPolicy openapi.RedirectPolicy +} + +func NewConfiguration() *Configuration { + return &Configuration{ + basePath: "https://example.com/nupf-ee/v1", + url: "{apiRoot}/nupf-ee/v1", + defaultHeader: make(map[string]string), + userAgent: "OpenAPI-Generator/1.0.0/go", + MetricsHook: nil, + } +} + +func (c *Configuration) SetBasePath(apiRoot string) { + c.basePath = strings.Replace(c.url, "{"+"apiRoot"+"}", apiRoot, -1) +} + +func (c *Configuration) BasePath() string { + return c.basePath +} + +func (c *Configuration) Host() string { + return c.host +} + +func (c *Configuration) SetHost(host string) { + c.host = host +} + +func (c *Configuration) UserAgent() string { + return c.userAgent +} + +func (c *Configuration) SetUserAgent(userAgent string) { + c.userAgent = userAgent +} + +func (c *Configuration) DefaultHeader() map[string]string { + return c.defaultHeader +} + +func (c *Configuration) AddDefaultHeader(key string, value string) { + c.defaultHeader[key] = value +} + +func (c *Configuration) HTTPClient() *http.Client { + return c.httpClient +} + +func (c *Configuration) SetHTTPClient(client *http.Client) { + c.httpClient = client +} + +func (c *Configuration) Metrics() openapi.RequestMetricsHook { + return c.MetricsHook +} + +func (c *Configuration) SetMetrics(h openapi.RequestMetricsHook) { + c.MetricsHook = h +} + +func (c *Configuration) RedirectPolicy() openapi.RedirectPolicy { + return c.redirectPolicy +} + +func (c *Configuration) SetRedirectPolicy(policy openapi.RedirectPolicy) { + c.redirectPolicy = policy +}