Add configuration for block golomb filters

This commit is contained in:
Martin Boehm
2023-08-13 23:48:17 +02:00
parent 911454f171
commit 96dbc8c9dc
14 changed files with 136 additions and 123 deletions

View File

@@ -4,8 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"math/big"
"os"
"reflect"
"time"
@@ -140,23 +140,9 @@ func init() {
BlockChainFactories["Polygon Archive"] = polygon.NewPolygonRPC
}
// GetCoinNameFromConfig gets coin name and coin shortcut from config file
func GetCoinNameFromConfig(configFileContent []byte) (string, string, string, error) {
var cn struct {
CoinName string `json:"coin_name"`
CoinShortcut string `json:"coin_shortcut"`
CoinLabel string `json:"coin_label"`
}
err := json.Unmarshal(configFileContent, &cn)
if err != nil {
return "", "", "", errors.Annotatef(err, "Error parsing config file ")
}
return cn.CoinName, cn.CoinShortcut, cn.CoinLabel, nil
}
// NewBlockChain creates bchain.BlockChain and bchain.Mempool for the coin passed by the parameter coin
func NewBlockChain(coin string, configfile string, pushHandler func(bchain.NotificationType), metrics *common.Metrics) (bchain.BlockChain, bchain.Mempool, error) {
data, err := ioutil.ReadFile(configfile)
data, err := os.ReadFile(configfile)
if err != nil {
return nil, nil, errors.Annotatef(err, "Error reading file %v", configfile)
}

View File

@@ -6,7 +6,6 @@ import (
"encoding/hex"
"encoding/json"
"io"
"io/ioutil"
"math/big"
"net"
"net/http"
@@ -33,7 +32,7 @@ type BitcoinRPC struct {
mq *bchain.MQ
ChainConfig *Configuration
RPCMarshaler RPCMarshaler
golombFilterP uint8
mempoolGolombFilterP uint8
mempoolFilterScripts string
}
@@ -62,7 +61,7 @@ type Configuration struct {
AlternativeEstimateFee string `json:"alternative_estimate_fee,omitempty"`
AlternativeEstimateFeeParams string `json:"alternative_estimate_fee_params,omitempty"`
MinimumCoinbaseConfirmations int `json:"minimumCoinbaseConfirmations,omitempty"`
GolombFilterP uint8 `json:"golomb_filter_p,omitempty"`
MempoolGolombFilterP uint8 `json:"mempool_golomb_filter_p,omitempty"`
MempoolFilterScripts string `json:"mempool_filter_scripts,omitempty"`
}
@@ -109,7 +108,7 @@ func NewBitcoinRPC(config json.RawMessage, pushHandler func(bchain.NotificationT
ChainConfig: &c,
pushHandler: pushHandler,
RPCMarshaler: JSONMarshalerV2{},
golombFilterP: c.GolombFilterP,
mempoolGolombFilterP: c.MempoolGolombFilterP,
mempoolFilterScripts: c.MempoolFilterScripts,
}
@@ -156,7 +155,7 @@ func (b *BitcoinRPC) Initialize() error {
// CreateMempool creates mempool if not already created, however does not initialize it
func (b *BitcoinRPC) CreateMempool(chain bchain.BlockChain) (bchain.Mempool, error) {
if b.Mempool == nil {
b.Mempool = bchain.NewMempoolBitcoinType(chain, b.ChainConfig.MempoolWorkers, b.ChainConfig.MempoolSubWorkers, b.golombFilterP, b.mempoolFilterScripts)
b.Mempool = bchain.NewMempoolBitcoinType(chain, b.ChainConfig.MempoolWorkers, b.ChainConfig.MempoolSubWorkers, b.mempoolGolombFilterP, b.mempoolFilterScripts)
}
return b.Mempool, nil
}
@@ -891,7 +890,7 @@ func safeDecodeResponse(body io.ReadCloser, res interface{}) (err error) {
}
}
}()
data, err = ioutil.ReadAll(body)
data, err = io.ReadAll(body)
if err != nil {
return err
}

View File

@@ -2,7 +2,6 @@ package main
import (
"context"
"encoding/json"
"flag"
"log"
"math/rand"
@@ -152,30 +151,19 @@ func mainWithExitCode() int {
return exitCodeOK
}
if *configFile == "" {
glog.Error("Missing blockchaincfg configuration parameter")
return exitCodeFatal
}
configFileContent, err := os.ReadFile(*configFile)
if err != nil {
glog.Errorf("Error reading file %v, %v", configFile, err)
return exitCodeFatal
}
coin, coinShortcut, coinLabel, err := coins.GetCoinNameFromConfig(configFileContent)
config, err := common.GetConfig(*configFile)
if err != nil {
glog.Error("config: ", err)
return exitCodeFatal
}
metrics, err = common.GetMetrics(coin)
metrics, err = common.GetMetrics(config.CoinName)
if err != nil {
glog.Error("metrics: ", err)
return exitCodeFatal
}
if chain, mempool, err = getBlockChainWithRetry(coin, *configFile, pushSynchronizationHandler, metrics, 120); err != nil {
if chain, mempool, err = getBlockChainWithRetry(config.CoinName, *configFile, pushSynchronizationHandler, metrics, 120); err != nil {
glog.Error("rpc: ", err)
return exitCodeFatal
}
@@ -187,7 +175,7 @@ func mainWithExitCode() int {
}
defer index.Close()
internalState, err = newInternalState(coin, coinShortcut, coinLabel, index, *enableSubNewTx)
internalState, err = newInternalState(config, index, *enableSubNewTx)
if err != nil {
glog.Error("internalState: ", err)
return exitCodeFatal
@@ -279,7 +267,7 @@ func mainWithExitCode() int {
return exitCodeFatal
}
if fiatRates, err = fiat.NewFiatRates(index, configFileContent, metrics, onNewFiatRatesTicker); err != nil {
if fiatRates, err = fiat.NewFiatRates(index, config, metrics, onNewFiatRatesTicker); err != nil {
glog.Error("fiatRates ", err)
return exitCodeFatal
}
@@ -368,7 +356,7 @@ func mainWithExitCode() int {
if internalServer != nil || publicServer != nil || chain != nil {
// start fiat rates downloader only if not shutting down immediately
initDownloaders(index, chain, configFileContent)
initDownloaders(index, chain, config)
waitForSignalAndShutdown(internalServer, publicServer, chain, 10*time.Second)
}
@@ -501,16 +489,12 @@ func blockbookAppInfoMetric(db *db.RocksDB, chain bchain.BlockChain, txCache *db
return nil
}
func newInternalState(coin, coinShortcut, coinLabel string, d *db.RocksDB, enableSubNewTx bool) (*common.InternalState, error) {
is, err := d.LoadInternalState(coin)
func newInternalState(config *common.Config, d *db.RocksDB, enableSubNewTx bool) (*common.InternalState, error) {
is, err := d.LoadInternalState(config)
if err != nil {
return nil, err
}
is.CoinShortcut = coinShortcut
if coinLabel == "" {
coinLabel = coin
}
is.CoinLabel = coinLabel
is.EnableSubNewTx = enableSubNewTx
name, err := os.Hostname()
if err != nil {
@@ -702,21 +686,11 @@ func computeFeeStats(stopCompute chan os.Signal, blockFrom, blockTo int, db *db.
return err
}
func initDownloaders(db *db.RocksDB, chain bchain.BlockChain, configFileContent []byte) {
func initDownloaders(db *db.RocksDB, chain bchain.BlockChain, config *common.Config) {
if fiatRates.Enabled {
go fiatRates.RunDownloader()
}
var config struct {
FourByteSignatures string `json:"fourByteSignatures"`
}
err := json.Unmarshal(configFileContent, &config)
if err != nil {
glog.Errorf("Error parsing config file %v, %v", *configFile, err)
return
}
if config.FourByteSignatures != "" && chain.GetChainParser().GetChainType() == bchain.ChainEthereumType {
fbsd, err := fourbyte.NewFourByteSignaturesDownloader(db, config.FourByteSignatures)
if err != nil {

40
common/config.go Normal file
View File

@@ -0,0 +1,40 @@
package common
import (
"encoding/json"
"os"
"github.com/juju/errors"
)
// Config struct
type Config struct {
CoinName string `json:"coin_name"`
CoinShortcut string `json:"coin_shortcut"`
CoinLabel string `json:"coin_label"`
FourByteSignatures string `json:"fourByteSignatures"`
FiatRates string `json:"fiat_rates"`
FiatRatesParams string `json:"fiat_rates_params"`
FiatRatesVsCurrencies string `json:"fiat_rates_vs_currencies"`
BlockGolombFilterP uint8 `json:"block_golomb_filter_p"`
BlockFilterScripts string `json:"block_filter_scripts"`
}
// GetConfig loads and parses the config file and returns Config struct
func GetConfig(configFile string) (*Config, error) {
if configFile == "" {
return nil, errors.New("Missing blockchaincfg configuration parameter")
}
configFileContent, err := os.ReadFile(configFile)
if err != nil {
return nil, errors.Errorf("Error reading file %v, %v", configFile, err)
}
var cn Config
err = json.Unmarshal(configFileContent, &cn)
if err != nil {
return nil, errors.Annotatef(err, "Error parsing config file ")
}
return &cn, nil
}

View File

@@ -93,9 +93,9 @@ type InternalState struct {
UtxoChecked bool `json:"utxoChecked"`
SortedAddressContracts bool `json:"sortedAddressContracts"`
// TODO: add golombFilterP for block filters and check it at each startup
// if consistent with supplied config value
// Change of this value would require reindex
// golomb filter settings
BlockGolombFilterP uint8 `json:"block_golomb_filter_p"`
BlockFilterScripts string `json:"block_filter_scripts"`
}
// StartedSync signals start of synchronization

View File

@@ -68,7 +68,9 @@
"fiat_rates": "coingecko",
"fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH",
"fiat_rates_params": "{\"coin\": \"bitcoin\", \"periodSeconds\": 900}",
"golomb_filter_p": 20,
"block_golomb_filter_p": 20,
"block_filter_scripts": "taproot",
"mempool_golomb_filter_p": 20,
"mempool_filter_scripts": "taproot"
}
}

View File

@@ -64,7 +64,9 @@
"xpub_magic_segwit_native": 73342198,
"slip44": 1,
"additional_params": {
"golomb_filter_p": 20,
"block_golomb_filter_p": 20,
"block_filter_scripts": "taproot",
"mempool_golomb_filter_p": 20,
"mempool_filter_scripts": "taproot"
}
}

View File

@@ -41,10 +41,10 @@
"deprecatedrpc": "estimatefee"
},
"platforms": {
"arm64": {
"binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v25.0/groestlcoin-25.0-aarch64-linux-gnu.tar.gz",
"verification_source": "d8776b405113b46d6be6e4921c5a5e62cbfaa5329087abbec14cc24d750f9c94"
}
"arm64": {
"binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v25.0/groestlcoin-25.0-aarch64-linux-gnu.tar.gz",
"verification_source": "d8776b405113b46d6be6e4921c5a5e62cbfaa5329087abbec14cc24d750f9c94"
}
}
},
"blockbook": {
@@ -66,9 +66,7 @@
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH",
"fiat_rates_params": "{\"coin\": \"groestlcoin\", \"periodSeconds\": 900}",
"golomb_filter_p": 20,
"mempool_filter_scripts": "taproot"
"fiat_rates_params": "{\"coin\": \"groestlcoin\", \"periodSeconds\": 900}"
}
}
},

View File

@@ -41,10 +41,10 @@
"deprecatedrpc": "estimatefee"
},
"platforms": {
"arm64": {
"binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v25.0/groestlcoin-25.0-aarch64-linux-gnu.tar.gz",
"verification_source": "d8776b405113b46d6be6e4921c5a5e62cbfaa5329087abbec14cc24d750f9c94"
}
"arm64": {
"binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v25.0/groestlcoin-25.0-aarch64-linux-gnu.tar.gz",
"verification_source": "d8776b405113b46d6be6e4921c5a5e62cbfaa5329087abbec14cc24d750f9c94"
}
}
},
"blockbook": {
@@ -62,11 +62,7 @@
"xpub_magic": 70617039,
"xpub_magic_segwit_p2sh": 71979618,
"xpub_magic_segwit_native": 73342198,
"slip44": 1,
"additional_params": {
"golomb_filter_p": 20,
"mempool_filter_scripts": "taproot"
}
"slip44": 1
}
},
"meta": {

View File

@@ -1874,7 +1874,7 @@ func (d *RocksDB) checkColumns(is *common.InternalState) ([]common.InternalState
}
// LoadInternalState loads from db internal state or initializes a new one if not yet stored
func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, error) {
func (d *RocksDB) LoadInternalState(config *common.Config) (*common.InternalState, error) {
val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(internalStateKey))
if err != nil {
return nil, err
@@ -1883,7 +1883,14 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro
data := val.Data()
var is *common.InternalState
if len(data) == 0 {
is = &common.InternalState{Coin: rpcCoin, UtxoChecked: true, SortedAddressContracts: true, ExtendedIndex: d.extendedIndex}
is = &common.InternalState{
Coin: config.CoinName,
UtxoChecked: true,
SortedAddressContracts: true,
ExtendedIndex: d.extendedIndex,
BlockGolombFilterP: config.BlockGolombFilterP,
BlockFilterScripts: config.BlockFilterScripts,
}
} else {
is, err = common.UnpackInternalState(data)
if err != nil {
@@ -1892,14 +1899,19 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro
// verify that the rpc coin matches DB coin
// running it mismatched would corrupt the database
if is.Coin == "" {
is.Coin = rpcCoin
} else if is.Coin != rpcCoin {
return nil, errors.Errorf("Coins do not match. DB coin %v, RPC coin %v", is.Coin, rpcCoin)
is.Coin = config.CoinName
} else if is.Coin != config.CoinName {
return nil, errors.Errorf("Coins do not match. DB coin %v, RPC coin %v", is.Coin, config.CoinName)
}
if is.ExtendedIndex != d.extendedIndex {
return nil, errors.Errorf("ExtendedIndex setting does not match. DB extendedIndex %v, extendedIndex in options %v", is.ExtendedIndex, d.extendedIndex)
}
// TODO: verify the block filter P and error if it does not match
if is.BlockGolombFilterP != config.BlockGolombFilterP {
return nil, errors.Errorf("BlockGolombFilterP does not match. DB BlockGolombFilterP %v, config BlockGolombFilterP %v", is.BlockGolombFilterP, config.BlockGolombFilterP)
}
if is.BlockFilterScripts != config.BlockFilterScripts {
return nil, errors.Errorf("BlockFilterScripts does not match. DB BlockFilterScripts %v, config BlockFilterScripts %v", is.BlockFilterScripts, config.BlockFilterScripts)
}
}
nc, err := d.checkColumns(is)
if err != nil {
@@ -1930,6 +1942,13 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro
glog.Infof("loaded %d address alias records", recordsCount)
}
is.CoinShortcut = config.CoinShortcut
if config.CoinLabel == "" {
is.CoinLabel = config.CoinName
} else {
is.CoinLabel = config.CoinLabel
}
return is, nil
}

View File

@@ -5,7 +5,6 @@ package db
import (
"encoding/binary"
"encoding/hex"
"io/ioutil"
"math/big"
"os"
"reflect"
@@ -44,7 +43,7 @@ func bitcoinTestnetParser() *btc.BitcoinParser {
}
func setupRocksDB(t *testing.T, p bchain.BlockChainParser) *RocksDB {
tmp, err := ioutil.TempDir("", "testdb")
tmp, err := os.MkdirTemp("", "testdb")
if err != nil {
t.Fatal(err)
}
@@ -52,7 +51,7 @@ func setupRocksDB(t *testing.T, p bchain.BlockChainParser) *RocksDB {
if err != nil {
t.Fatal(err)
}
is, err := d.LoadInternalState("coin-unittest")
is, err := d.LoadInternalState(&common.Config{CoinName: "coin-unittest"})
if err != nil {
t.Fatal(err)
}

View File

@@ -61,16 +61,7 @@ type FiatRates struct {
}
// NewFiatRates initializes the FiatRates handler
func NewFiatRates(db *db.RocksDB, configFileContent []byte, metrics *common.Metrics, callback OnNewFiatRatesTicker) (*FiatRates, error) {
var config struct {
FiatRates string `json:"fiat_rates"`
FiatRatesParams string `json:"fiat_rates_params"`
FiatRatesVsCurrencies string `json:"fiat_rates_vs_currencies"`
}
err := json.Unmarshal(configFileContent, &config)
if err != nil {
return nil, fmt.Errorf("error parsing config file, %v", err)
}
func NewFiatRates(db *db.RocksDB, config *common.Config, metrics *common.Metrics, callback OnNewFiatRatesTicker) (*FiatRates, error) {
var fr = &FiatRates{
provider: config.FiatRates,
@@ -91,7 +82,7 @@ func NewFiatRates(db *db.RocksDB, configFileContent []byte, metrics *common.Metr
PeriodSeconds int64 `json:"periodSeconds"`
}
rdParams := &fiatRatesParams{}
err = json.Unmarshal([]byte(config.FiatRatesParams), &rdParams)
err := json.Unmarshal([]byte(config.FiatRatesParams), &rdParams)
if err != nil {
return nil, err
}

View File

@@ -30,7 +30,7 @@ func TestMain(m *testing.M) {
os.Exit(c)
}
func setupRocksDB(t *testing.T, parser bchain.BlockChainParser) (*db.RocksDB, *common.InternalState, string) {
func setupRocksDB(t *testing.T, parser bchain.BlockChainParser, config *common.Config) (*db.RocksDB, *common.InternalState, string) {
tmp, err := os.MkdirTemp("", "testdb")
if err != nil {
t.Fatal(err)
@@ -39,7 +39,7 @@ func setupRocksDB(t *testing.T, parser bchain.BlockChainParser) (*db.RocksDB, *c
if err != nil {
t.Fatal(err)
}
is, err := d.LoadInternalState("fakecoin")
is, err := d.LoadInternalState(config)
if err != nil {
t.Fatal(err)
}
@@ -83,11 +83,6 @@ func getFiatRatesMockData(name string) (string, error) {
}
func TestFiatRates(t *testing.T) {
d, _, tmp := setupRocksDB(t, &testBitcoinParser{
BitcoinParser: bitcoinTestnetParser(),
})
defer closeAndDestroyRocksDB(t, d, tmp)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
var mockData string
@@ -129,10 +124,19 @@ func TestFiatRates(t *testing.T) {
}))
defer mockServer.Close()
// mocked CoinGecko API
configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"` + mockServer.URL + `\", \"coin\": \"ethereum\",\"platformIdentifier\":\"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 60}"}`
// config with mocked CoinGecko API
config := common.Config{
CoinName: "fakecoin",
FiatRates: "coingecko",
FiatRatesParams: `{"url": "` + mockServer.URL + `", "coin": "ethereum","platformIdentifier": "ethereum","platformVsCurrency": "eth","periodSeconds": 60}`,
}
fiatRates, err := NewFiatRates(d, []byte(configJSON), nil, nil)
d, _, tmp := setupRocksDB(t, &testBitcoinParser{
BitcoinParser: bitcoinTestnetParser(),
}, &config)
defer closeAndDestroyRocksDB(t, d, tmp)
fiatRates, err := NewFiatRates(d, &config, nil, nil)
if err != nil {
t.Fatalf("FiatRates init error: %v", err)
}

View File

@@ -4,7 +4,7 @@ package server
import (
"encoding/json"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"net/url"
@@ -38,8 +38,8 @@ func TestMain(m *testing.M) {
os.Exit(c)
}
func setupRocksDB(parser bchain.BlockChainParser, chain bchain.BlockChain, t *testing.T, extendedIndex bool) (*db.RocksDB, *common.InternalState, string) {
tmp, err := ioutil.TempDir("", "testdb")
func setupRocksDB(parser bchain.BlockChainParser, chain bchain.BlockChain, t *testing.T, extendedIndex bool, config *common.Config) (*db.RocksDB, *common.InternalState, string) {
tmp, err := os.MkdirTemp("", "testdb")
if err != nil {
t.Fatal(err)
}
@@ -47,7 +47,7 @@ func setupRocksDB(parser bchain.BlockChainParser, chain bchain.BlockChain, t *te
if err != nil {
t.Fatal(err)
}
is, err := d.LoadInternalState("fakecoin")
is, err := d.LoadInternalState(config)
if err != nil {
t.Fatal(err)
}
@@ -95,11 +95,16 @@ func setupRocksDB(parser bchain.BlockChainParser, chain bchain.BlockChain, t *te
var metrics *common.Metrics
func setupPublicHTTPServer(parser bchain.BlockChainParser, chain bchain.BlockChain, t *testing.T, extendedIndex bool) (*PublicServer, string) {
d, is, path := setupRocksDB(parser, chain, t, extendedIndex)
// setup internal state and match BestHeight to test data
is.Coin = "Fakecoin"
is.CoinLabel = "Fake Coin"
is.CoinShortcut = "FAKE"
// config with mocked CoinGecko API
config := common.Config{
CoinName: "Fakecoin",
CoinLabel: "Fake Coin",
CoinShortcut: "FAKE",
FiatRates: "coingecko",
FiatRatesParams: `{"url": "none", "coin": "ethereum","platformIdentifier": "ethereum","platformVsCurrency": "usd","periodSeconds": 60}`,
}
d, is, path := setupRocksDB(parser, chain, t, extendedIndex, &config)
var err error
// metrics can be setup only once
@@ -121,9 +126,7 @@ func setupPublicHTTPServer(parser bchain.BlockChainParser, chain bchain.BlockCha
glog.Fatal("txCache: ", err)
}
// mocked CoinGecko API
configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"none\", \"coin\": \"ethereum\",\"platformIdentifier\":\"ethereum\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 60}"}`
fiatRates, err := fiat.NewFiatRates(d, []byte(configJSON), nil, nil)
fiatRates, err := fiat.NewFiatRates(d, &config, nil, nil)
if err != nil {
glog.Fatal("fiatRates ", err)
}
@@ -252,7 +255,7 @@ func performHttpTests(tests []httpTests, t *testing.T, ts *httptest.Server) {
if resp.Header["Content-Type"][0] != tt.contentType {
t.Errorf("Content-Type = %v, want %v", resp.Header["Content-Type"][0], tt.contentType)
}
bb, err := ioutil.ReadAll(resp.Body)
bb, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
@@ -1618,7 +1621,7 @@ func httpTestsExtendedIndex(t *testing.T, ts *httptest.Server) {
if resp.Header["Content-Type"][0] != tt.contentType {
t.Errorf("Content-Type = %v, want %v", resp.Header["Content-Type"][0], tt.contentType)
}
bb, err := ioutil.ReadAll(resp.Body)
bb, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}