ooni-probe-cli/internal/engine/probeservices/collector.go
Simone Basso eed51978ca
refactor(httpx): hide the real APIClient (#648)
As mentioned in https://github.com/ooni/probe/issues/1951, one of
the main issues I did see with httpx.APIClient is that in some cases
it's used in a very fragile way by probeservices.Client.

This happens in psiphon.go and tor.go, where we create a copy of
the APIClient and then modify it's Authorization field.

If we ever refactor probeservices.Client to take a pointer to
httpx.Client, we are now mutating the httpx.Client.

Of course, we don't want that to happen.

This diff attempts to address such a problem as follows:

1. we create a new APIClientTemplate type that holds the same
fields of an APIClient and allows to build an APIClient

2. we modify every user of APIClient to use APIClientTemplate

3. when we need an APIClient, we build it from the corresponding
template and, when we need to use a specific Authorization, we
use a build factory that sets APIClient.Authorization

4. we hide APIClient by renaming it apiClient and by defining
an interface called APIClient that allows to use it

So, now the codebase always uses the opaque APIClient interface to
issue API calls and always uses the APIClientTemplate to build an
opaque APIClient.

Boom! We have separated construction from usage and we are not
mutating in weird ways the APIClient anymore.
2022-01-05 14:15:42 +01:00

215 lines
6.5 KiB
Go

package probeservices
import (
"context"
"errors"
"fmt"
"reflect"
"sync"
"github.com/ooni/probe-cli/v3/internal/model"
)
const (
// DefaultDataFormatVersion is the default data format version.
//
// See https://github.com/ooni/spec/tree/master/data-formats#history.
DefaultDataFormatVersion = "0.2.0"
// DefaultFormat is the default format
DefaultFormat = "json"
)
var (
// ErrUnsupportedDataFormatVersion indicates that the user provided
// in input a data format version that we do not support.
ErrUnsupportedDataFormatVersion = errors.New("Unsupported data format version")
// ErrUnsupportedFormat indicates that the format is not supported.
ErrUnsupportedFormat = errors.New("Unsupported format")
// ErrJSONFormatNotSupported indicates that the collector we're using
// does not support the JSON report format.
ErrJSONFormatNotSupported = errors.New("JSON format not supported")
)
// ReportTemplate is the template for opening a report
type ReportTemplate struct {
// DataFormatVersion is unconditionally set to DefaultDataFormatVersion
// and you don't need to be concerned about it.
DataFormatVersion string `json:"data_format_version"`
// Format is unconditionally set to `json` and you don't need
// to be concerned about it.
Format string `json:"format"`
// ProbeASN is the probe's autonomous system number (e.g. `AS1234`)
ProbeASN string `json:"probe_asn"`
// ProbeCC is the probe's country code (e.g. `IT`)
ProbeCC string `json:"probe_cc"`
// SoftwareName is the app name (e.g. `measurement-kit`)
SoftwareName string `json:"software_name"`
// SoftwareVersion is the app version (e.g. `0.9.1`)
SoftwareVersion string `json:"software_version"`
// TestName is the test name (e.g. `ndt`)
TestName string `json:"test_name"`
// TestStartTime contains the test start time
TestStartTime string `json:"test_start_time"`
// TestVersion is the test version (e.g. `1.0.1`)
TestVersion string `json:"test_version"`
}
// NewReportTemplate creates a new ReportTemplate from a Measurement.
func NewReportTemplate(m *model.Measurement) ReportTemplate {
return ReportTemplate{
DataFormatVersion: DefaultDataFormatVersion,
Format: DefaultFormat,
ProbeASN: m.ProbeASN,
ProbeCC: m.ProbeCC,
SoftwareName: m.SoftwareName,
SoftwareVersion: m.SoftwareVersion,
TestName: m.TestName,
TestStartTime: m.TestStartTime,
TestVersion: m.TestVersion,
}
}
type collectorOpenResponse struct {
ID string `json:"report_id"`
SupportedFormats []string `json:"supported_formats"`
}
type reportChan struct {
// ID is the report ID
ID string
// client is the client that was used.
client Client
// tmpl is the template used when opening this report.
tmpl ReportTemplate
}
// OpenReport opens a new report.
func (c Client) OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error) {
if rt.DataFormatVersion != DefaultDataFormatVersion {
return nil, ErrUnsupportedDataFormatVersion
}
if rt.Format != DefaultFormat {
return nil, ErrUnsupportedFormat
}
var cor collectorOpenResponse
if err := c.APIClientTemplate.Build().PostJSON(ctx, "/report", rt, &cor); err != nil {
return nil, err
}
for _, format := range cor.SupportedFormats {
if format == "json" {
return &reportChan{ID: cor.ID, client: c, tmpl: rt}, nil
}
}
return nil, ErrJSONFormatNotSupported
}
type collectorUpdateRequest struct {
// Format is the data format
Format string `json:"format"`
// Content is the actual report
Content interface{} `json:"content"`
}
type collectorUpdateResponse struct {
// ID is the measurement ID
ID string `json:"measurement_id"`
}
// CanSubmit returns true whether the provided measurement belongs to
// this report, false otherwise. We say that a given measurement belongs
// to this report if its report template matches the report's one.
func (r reportChan) CanSubmit(m *model.Measurement) bool {
return reflect.DeepEqual(NewReportTemplate(m), r.tmpl)
}
// SubmitMeasurement submits a measurement belonging to the report
// to the OONI collector. On success, we will modify the measurement
// such that it contains the report ID for which it has been
// submitted. Otherwise, we'll set the report ID to the empty
// string, so that you know which measurements weren't submitted.
func (r reportChan) SubmitMeasurement(ctx context.Context, m *model.Measurement) error {
var updateResponse collectorUpdateResponse
m.ReportID = r.ID
err := r.client.APIClientTemplate.Build().PostJSON(
ctx, fmt.Sprintf("/report/%s", r.ID), collectorUpdateRequest{
Format: "json",
Content: m,
}, &updateResponse,
)
if err != nil {
m.ReportID = ""
return err
}
return nil
}
// ReportID returns the report ID.
func (r reportChan) ReportID() string {
return r.ID
}
// ReportChannel is a channel through which one could submit measurements
// belonging to the same report. The Report struct belongs to this interface.
type ReportChannel interface {
CanSubmit(m *model.Measurement) bool
ReportID() string
SubmitMeasurement(ctx context.Context, m *model.Measurement) error
}
var _ ReportChannel = &reportChan{}
// ReportOpener is any struct that is able to open a new ReportChannel. The
// Client struct belongs to this interface.
type ReportOpener interface {
OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error)
}
var _ ReportOpener = Client{}
// Submitter is an abstraction allowing you to submit arbitrary measurements
// to a given OONI backend. This implementation will take care of opening
// reports when needed as well as of closing reports when needed. Nonetheless
// you need to remember to call its Close method when done, because there is
// likely an open report that has not been closed yet.
type Submitter struct {
channel ReportChannel
logger model.Logger
mu sync.Mutex
opener ReportOpener
}
// NewSubmitter creates a new Submitter instance.
func NewSubmitter(opener ReportOpener, logger model.Logger) *Submitter {
return &Submitter{opener: opener, logger: logger}
}
// Submit submits the current measurement to the OONI backend created using
// the ReportOpener passed to the constructor.
func (sub *Submitter) Submit(ctx context.Context, m *model.Measurement) error {
var err error
sub.mu.Lock()
defer sub.mu.Unlock()
if sub.channel == nil || !sub.channel.CanSubmit(m) {
sub.channel, err = sub.opener.OpenReport(ctx, NewReportTemplate(m))
if err != nil {
return err
}
sub.logger.Infof("New reportID: %s", sub.channel.ReportID())
}
return sub.channel.SubmitMeasurement(ctx, m)
}