1package probeservices 2 3import ( 4 "context" 5 "errors" 6 "fmt" 7 "reflect" 8 "sync" 9 10 "github.com/ooni/probe-cli/v3/internal/engine/model" 11) 12 13const ( 14 // DefaultDataFormatVersion is the default data format version. 15 // 16 // See https://github.com/ooni/spec/tree/master/data-formats#history. 17 DefaultDataFormatVersion = "0.2.0" 18 19 // DefaultFormat is the default format 20 DefaultFormat = "json" 21) 22 23var ( 24 // ErrUnsupportedDataFormatVersion indicates that the user provided 25 // in input a data format version that we do not support. 26 ErrUnsupportedDataFormatVersion = errors.New("Unsupported data format version") 27 28 // ErrUnsupportedFormat indicates that the format is not supported. 29 ErrUnsupportedFormat = errors.New("Unsupported format") 30 31 // ErrJSONFormatNotSupported indicates that the collector we're using 32 // does not support the JSON report format. 33 ErrJSONFormatNotSupported = errors.New("JSON format not supported") 34) 35 36// ReportTemplate is the template for opening a report 37type ReportTemplate struct { 38 // DataFormatVersion is unconditionally set to DefaultDataFormatVersion 39 // and you don't need to be concerned about it. 40 DataFormatVersion string `json:"data_format_version"` 41 42 // Format is unconditionally set to `json` and you don't need 43 // to be concerned about it. 44 Format string `json:"format"` 45 46 // ProbeASN is the probe's autonomous system number (e.g. `AS1234`) 47 ProbeASN string `json:"probe_asn"` 48 49 // ProbeCC is the probe's country code (e.g. `IT`) 50 ProbeCC string `json:"probe_cc"` 51 52 // SoftwareName is the app name (e.g. `measurement-kit`) 53 SoftwareName string `json:"software_name"` 54 55 // SoftwareVersion is the app version (e.g. `0.9.1`) 56 SoftwareVersion string `json:"software_version"` 57 58 // TestName is the test name (e.g. `ndt`) 59 TestName string `json:"test_name"` 60 61 // TestStartTime contains the test start time 62 TestStartTime string `json:"test_start_time"` 63 64 // TestVersion is the test version (e.g. `1.0.1`) 65 TestVersion string `json:"test_version"` 66} 67 68// NewReportTemplate creates a new ReportTemplate from a Measurement. 69func NewReportTemplate(m *model.Measurement) ReportTemplate { 70 return ReportTemplate{ 71 DataFormatVersion: DefaultDataFormatVersion, 72 Format: DefaultFormat, 73 ProbeASN: m.ProbeASN, 74 ProbeCC: m.ProbeCC, 75 SoftwareName: m.SoftwareName, 76 SoftwareVersion: m.SoftwareVersion, 77 TestName: m.TestName, 78 TestStartTime: m.TestStartTime, 79 TestVersion: m.TestVersion, 80 } 81} 82 83type collectorOpenResponse struct { 84 ID string `json:"report_id"` 85 SupportedFormats []string `json:"supported_formats"` 86} 87 88type reportChan struct { 89 // ID is the report ID 90 ID string 91 92 // client is the client that was used. 93 client Client 94 95 // tmpl is the template used when opening this report. 96 tmpl ReportTemplate 97} 98 99// OpenReport opens a new report. 100func (c Client) OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error) { 101 if rt.DataFormatVersion != DefaultDataFormatVersion { 102 return nil, ErrUnsupportedDataFormatVersion 103 } 104 if rt.Format != DefaultFormat { 105 return nil, ErrUnsupportedFormat 106 } 107 var cor collectorOpenResponse 108 if err := c.Client.PostJSON(ctx, "/report", rt, &cor); err != nil { 109 return nil, err 110 } 111 for _, format := range cor.SupportedFormats { 112 if format == "json" { 113 return &reportChan{ID: cor.ID, client: c, tmpl: rt}, nil 114 } 115 } 116 return nil, ErrJSONFormatNotSupported 117} 118 119type collectorUpdateRequest struct { 120 // Format is the data format 121 Format string `json:"format"` 122 123 // Content is the actual report 124 Content interface{} `json:"content"` 125} 126 127type collectorUpdateResponse struct { 128 // ID is the measurement ID 129 ID string `json:"measurement_id"` 130} 131 132// CanSubmit returns true whether the provided measurement belongs to 133// this report, false otherwise. We say that a given measurement belongs 134// to this report if its report template matches the report's one. 135func (r reportChan) CanSubmit(m *model.Measurement) bool { 136 return reflect.DeepEqual(NewReportTemplate(m), r.tmpl) 137} 138 139// SubmitMeasurement submits a measurement belonging to the report 140// to the OONI collector. On success, we will modify the measurement 141// such that it contains the report ID for which it has been 142// submitted. Otherwise, we'll set the report ID to the empty 143// string, so that you know which measurements weren't submitted. 144func (r reportChan) SubmitMeasurement(ctx context.Context, m *model.Measurement) error { 145 var updateResponse collectorUpdateResponse 146 m.ReportID = r.ID 147 err := r.client.Client.PostJSON( 148 ctx, fmt.Sprintf("/report/%s", r.ID), collectorUpdateRequest{ 149 Format: "json", 150 Content: m, 151 }, &updateResponse, 152 ) 153 if err != nil { 154 m.ReportID = "" 155 return err 156 } 157 return nil 158} 159 160// ReportID returns the report ID. 161func (r reportChan) ReportID() string { 162 return r.ID 163} 164 165// ReportChannel is a channel through which one could submit measurements 166// belonging to the same report. The Report struct belongs to this interface. 167type ReportChannel interface { 168 CanSubmit(m *model.Measurement) bool 169 ReportID() string 170 SubmitMeasurement(ctx context.Context, m *model.Measurement) error 171} 172 173var _ ReportChannel = &reportChan{} 174 175// ReportOpener is any struct that is able to open a new ReportChannel. The 176// Client struct belongs to this interface. 177type ReportOpener interface { 178 OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error) 179} 180 181var _ ReportOpener = Client{} 182 183// Submitter is an abstraction allowing you to submit arbitrary measurements 184// to a given OONI backend. This implementation will take care of opening 185// reports when needed as well as of closing reports when needed. Nonetheless 186// you need to remember to call its Close method when done, because there is 187// likely an open report that has not been closed yet. 188type Submitter struct { 189 channel ReportChannel 190 logger model.Logger 191 mu sync.Mutex 192 opener ReportOpener 193} 194 195// NewSubmitter creates a new Submitter instance. 196func NewSubmitter(opener ReportOpener, logger model.Logger) *Submitter { 197 return &Submitter{opener: opener, logger: logger} 198} 199 200// Submit submits the current measurement to the OONI backend created using 201// the ReportOpener passed to the constructor. 202func (sub *Submitter) Submit(ctx context.Context, m *model.Measurement) error { 203 var err error 204 sub.mu.Lock() 205 defer sub.mu.Unlock() 206 if sub.channel == nil || !sub.channel.CanSubmit(m) { 207 sub.channel, err = sub.opener.OpenReport(ctx, NewReportTemplate(m)) 208 if err != nil { 209 return err 210 } 211 sub.logger.Infof("New reportID: %s", sub.channel.ReportID()) 212 } 213 return sub.channel.SubmitMeasurement(ctx, m) 214} 215