├── .gitignore ├── LICENSE ├── README.md ├── bid ├── bid.go ├── handler.go ├── log.go ├── request.go └── response.go ├── click ├── handler.go └── request.go ├── command ├── create_mock.go └── data.yml ├── common ├── consts │ └── consts.go ├── errors │ └── errors.go └── utils │ └── writer.go ├── config ├── config.go └── config.yml.def ├── data ├── ad.go └── index.go ├── examples ├── request.mobile.json ├── request.pc.single.json └── request.simple.json ├── fluent └── fluent.go ├── handler.go ├── main.go ├── redis └── redis.go └── win ├── handler.go └── request.go /.gitignore: -------------------------------------------------------------------------------- 1 | dist/version 2 | 3 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 4 | *.o 5 | *.a 6 | *.so 7 | 8 | # Folders 9 | _obj 10 | _test 11 | 12 | # Architecture specific extensions/prefixes 13 | *.[568vq] 14 | [568vq].out 15 | 16 | # Redis 17 | *.rdb 18 | 19 | *.cgo1.go 20 | *.cgo2.c 21 | _cgo_defun.c 22 | _cgo_gotypes.go 23 | _cgo_export.* 24 | 25 | _testmain.go 26 | 27 | *.exe 28 | *.test 29 | *.prof 30 | 31 | go-dsp-api 32 | command 33 | 34 | tmp 35 | .idea 36 | 37 | config.yml 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2016 Satoshi Innami 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, 7 | this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of The author nor the names of its contributors may 14 | be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE AUTHOR AND CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DSP API Go implementation 2 | 3 | Simple DSP(Demand Side Platform) API written in golang compliant with [OpenRTB v2.3](https://github.com/openrtb/OpenRTB/blob/master/OpenRTB-API-Specification-Version-2-3-FINAL.pdf) 4 | 5 | # Required middleware 6 | 7 | - [Redis](http://redis.io/download) 8 | - [fluentd](http://www.fluentd.org/download) 9 | 10 | # How to setup 11 | 12 | If required midllewares were not installed in your machine, please set up and run at first. 13 | 14 | Copy source to your local dierctory. 15 | 16 | ``` 17 | $ git clone git@github.com:satoshi03/go-dsp-api.git 18 | ``` 19 | 20 | Install go libs and compile. 21 | 22 | ``` 23 | $ go get 24 | $ go build 25 | ``` 26 | 27 | Copy config and edit. 28 | 29 | ``` 30 | $ cp config/config.yml.def config.yml 31 | $ vim config.yml 32 | ``` 33 | 34 | Run. 35 | 36 | ``` 37 | $ ./go-dsp-api 38 | ``` 39 | 40 | # End Points 41 | 42 | ## RTB Request [/v1/bid] POST 43 | 44 | Receive RTB request. RTB request must be compliant with [OpenRTB v2.3](https://github.com/openrtb/OpenRTB/blob/master/OpenRTB-API-Specification-Version-2-3-FINAL.pdf) 45 | 46 | + Response 200 (application/json) 47 | + Headers 48 | 49 | ``` 50 | x-openrtb-version: 2.3 51 | ``` 52 | 53 | + Body 54 | 55 | ``` 56 | { 57 | "cur": "JPY", 58 | "id": "IxexyLDIIk", 59 | "seatbid": [ 60 | { 61 | "bid": [ 62 | { 63 | "adid": "1234", 64 | "adm": "\"Advertisement\"", 65 | "cid": "1234", 66 | "crid": "12345", 67 | "id": "d8aa5114-cd86-4484-9947-b907c8d12daa", 68 | "impid": "1", 69 | "iurl": "http://test.noadnolife.com/img/12345.png", 70 | "nurl": "http://test.noadnolife.com/v1/win/12345?impid=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}", 71 | "price": 225 72 | } 73 | ] 74 | } 75 | ] 76 | } 77 | ``` 78 | 79 | + Response 204 (application/json) 80 | + Headers 81 | 82 | ``` 83 | x-openrtb-version: 2.3 84 | ``` 85 | 86 | ## Win Notice [/v1/win/:crid] GET 87 | 88 | Receive win notice. In order to track logs, price and impression ID are required. 89 | 90 | + Parameters 91 | + price (number, required) - Won price of RTB Auction 92 | + impid (number, required) - An impression ID of win notice 93 | 94 | 95 | + Response 200 (application/json) 96 | + Headers 97 | 98 | ``` 99 | x-openrtb-version: 2.3 100 | ``` 101 | 102 | + Body 103 | 104 | ``` 105 | { "message": "ok" } 106 | ``` 107 | 108 | ## Click [/v1/click/:crid] GET 109 | 110 | Receive click action log. In order to track logs, price and impression ID are required. 111 | 112 | + Parameters 113 | + price (number, required) - Won price of RTB Auction 114 | + impid (number, required) - An impression ID of win notice 115 | 116 | + Response 200 (application/json) 117 | + Headers 118 | 119 | ``` 120 | x-openrtb-version: 2.3 121 | ``` 122 | 123 | + Body 124 | 125 | ``` 126 | { "message": "ok" } 127 | ``` 128 | 129 | # Run with mock data 130 | 131 | Put mock data to Redis. 132 | 133 | ``` 134 | $ cd command 135 | $ go run create_mock.go 136 | ``` 137 | 138 | Send RTB Request via [httpie](https://github.com/jkbrzt/httpie) 139 | 140 | ``` 141 | $ http POST http:///v1/bid < examples/request.mobile.json 142 | ``` 143 | 144 | Send Win Notice 145 | 146 | ``` 147 | $ http GET http:///v1/win/XXXX impid==YYYYYYYY price==100 148 | ``` 149 | 150 | If you want to add/modify mock data, please edit data file. 151 | 152 | ``` 153 | $ vim command/data.yml 154 | ``` 155 | 156 | # License 157 | 158 | See [Licence](https://github.com/satoshi03/go-dsp-api/blob/master/LICENSE) 159 | -------------------------------------------------------------------------------- /bid/bid.go: -------------------------------------------------------------------------------- 1 | package bid 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/mxmCherry/openrtb" 7 | "golang.org/x/net/context" 8 | 9 | "github.com/satoshi03/go-dsp-api/common/consts" 10 | "github.com/satoshi03/go-dsp-api/common/errors" 11 | "github.com/satoshi03/go-dsp-api/data" 12 | ) 13 | 14 | func bid(ctx context.Context, br *openrtb.BidRequest) []*data.Ad { 15 | var selected []*data.Ad 16 | for _, imp := range br.Imp { 17 | // If imp is invalid, skip it 18 | if err := validateImp(&imp); err != nil { 19 | continue 20 | } 21 | ad, ok := getAd(ctx, &imp) 22 | if ok { 23 | ad.ImpID = imp.ID 24 | selected = append(selected, &ad) 25 | } 26 | } 27 | return selected 28 | } 29 | 30 | func getAd(ctx context.Context, imp *openrtb.Imp) (data.Ad, bool) { 31 | // Get Index having candidate ad list 32 | index, err := data.GetIndex(ctx, imp) 33 | if err != nil { 34 | // For debug 35 | log.Println(err) 36 | return data.Ad{}, false 37 | } 38 | 39 | // Find valid ad having max score(=ecpm) in index 40 | var validAds []data.Ad 41 | for i := range index { 42 | if err := validateAd(imp, &index[i]); err == nil { 43 | validAds = append(validAds, index[i]) 44 | } else { 45 | // For debug 46 | log.Println(err) 47 | } 48 | } 49 | // Choice high revenue ad in valid ads 50 | return choiceBestAd(validAds) 51 | } 52 | 53 | func choiceBestAd(va []data.Ad) (data.Ad, bool) { 54 | var revenue, maxRevenue float64 55 | index := -1 56 | for i := range va { 57 | revenue = va[i].PeCPM - va[i].CalcBidPrice() 58 | if revenue > maxRevenue { 59 | maxRevenue = revenue 60 | index = i 61 | } 62 | } 63 | if index == -1 { 64 | return data.Ad{}, false 65 | } 66 | return va[index], true 67 | } 68 | 69 | func validateImp(imp *openrtb.Imp) error { 70 | // Native Ad is not supported 71 | if imp.Native != nil { 72 | return errors.NoSupportError{"native"} 73 | } 74 | // Video Ad is not supported 75 | if imp.Video != nil { 76 | return errors.NoSupportError{"video"} 77 | } 78 | // Check bid currency 79 | if imp.BidFloorCur != "" && imp.BidFloorCur != consts.DefaultBidCur { 80 | return errors.InvalidCurError 81 | } 82 | return nil 83 | } 84 | 85 | func validateAd(imp *openrtb.Imp, ad *data.Ad) error { 86 | // Check bid price greater than bid floor price 87 | if ad.CalcBidPrice() <= imp.BidFloor { 88 | return errors.LowPriceError 89 | } 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /bid/handler.go: -------------------------------------------------------------------------------- 1 | package bid 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/guregu/kami" 8 | "github.com/mxmCherry/openrtb" 9 | "golang.org/x/net/context" 10 | 11 | "github.com/satoshi03/go-dsp-api/common/consts" 12 | "github.com/satoshi03/go-dsp-api/common/utils" 13 | "github.com/satoshi03/go-dsp-api/fluent" 14 | ) 15 | 16 | func bidHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) { 17 | // Parse bid request 18 | request, err := parseRequest(r) 19 | if err != nil { 20 | log.Println(err) 21 | noBidResponse(w) 22 | return 23 | } 24 | 25 | // Validate request 26 | if err := request.validate(); err != nil { 27 | log.Println(err) 28 | noBidResponse(w) 29 | return 30 | } 31 | 32 | // Bidding 33 | ads := bid(ctx, request.BidRequest) 34 | if len(ads) == 0 { 35 | noBidResponse(w) 36 | return 37 | } 38 | 39 | // Make Response 40 | resp := makeBidResponse(request.BidRequest, ads) 41 | 42 | // Send Response 43 | utils.WriteResponse(w, resp, 200) 44 | 45 | // Send log 46 | sendLog(ctx, request.BidRequest, resp) 47 | } 48 | 49 | func sendLog(ctx context.Context, req *openrtb.BidRequest, resp *openrtb.BidResponse) { 50 | fluent.Send(ctx, consts.CtxFluentKey, "request", makeRequestLog(req, resp)) 51 | for _, bid := range resp.SeatBid[0].Bid { 52 | fluent.Send(ctx, consts.CtxFluentKey, "bid", makeBidLog(&bid)) 53 | } 54 | } 55 | 56 | func noBidResponse(w http.ResponseWriter) { 57 | utils.WriteResponse(w, nil, 204) 58 | } 59 | 60 | func InitHandler() { 61 | kami.Post("/v1/bid", bidHandler) 62 | } 63 | -------------------------------------------------------------------------------- /bid/log.go: -------------------------------------------------------------------------------- 1 | package bid 2 | 3 | import ( 4 | "github.com/mxmCherry/openrtb" 5 | ) 6 | 7 | func makeRequestLog(r *openrtb.BidRequest, br *openrtb.BidResponse) map[string]interface{} { 8 | // TODO: add more request data (User, Device, etc...) 9 | return map[string]interface{}{ 10 | "request_id": r.ID, 11 | "cur": br.Cur, 12 | "bid_num": len(br.SeatBid[0].Bid), 13 | } 14 | } 15 | 16 | func makeBidLog(bid *openrtb.Bid) map[string]interface{} { 17 | return map[string]interface{}{ 18 | "bid_id": bid.ID, 19 | "imp_id": bid.ImpID, 20 | "price": bid.Price, 21 | "ad_id": bid.AdID, 22 | "campaign_id": bid.CID, 23 | "creative_id": bid.CrID, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /bid/request.go: -------------------------------------------------------------------------------- 1 | package bid 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | 8 | "github.com/mxmCherry/openrtb" 9 | 10 | "github.com/satoshi03/go-dsp-api/common/consts" 11 | "github.com/satoshi03/go-dsp-api/common/errors" 12 | ) 13 | 14 | type Request struct { 15 | BidRequest *openrtb.BidRequest 16 | } 17 | 18 | func parseRequest(r *http.Request) (*Request, error) { 19 | // Read bid request in json format 20 | body, err := ioutil.ReadAll(r.Body) 21 | if err != nil { 22 | return nil, err 23 | } 24 | // Unmarshal json to BidRequest 25 | var br openrtb.BidRequest 26 | err = json.Unmarshal(body, &br) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return &Request{ 31 | BidRequest: &br, 32 | }, nil 33 | } 34 | 35 | func (r *Request) validate() error { 36 | // Required attributes must be exsit 37 | if r.BidRequest.ID == "" { 38 | return errors.InvalidRequestParamError{"BidRequest.ID", ""} 39 | } 40 | if r.BidRequest.Imp == nil { 41 | return errors.InvalidRequestParamError{"BidRequest.Imp", ""} 42 | } 43 | 44 | // Currency type is valid 45 | if !r.validCurrency() { 46 | return errors.InvalidCurError 47 | } 48 | return nil 49 | } 50 | 51 | func (r *Request) validCurrency() bool { 52 | // currency not exist 53 | if len(r.BidRequest.Cur) == 0 { 54 | return true 55 | } 56 | // currency exist 57 | for _, cur := range r.BidRequest.Cur { 58 | if cur == consts.DefaultBidCur { 59 | // currency including default bid currency 60 | return true 61 | } 62 | } 63 | // currency NOT including default bid currency 64 | return false 65 | } 66 | -------------------------------------------------------------------------------- /bid/response.go: -------------------------------------------------------------------------------- 1 | package bid 2 | 3 | import ( 4 | "github.com/mxmCherry/openrtb" 5 | "github.com/pborman/uuid" 6 | 7 | "github.com/satoshi03/go-dsp-api/common/consts" 8 | "github.com/satoshi03/go-dsp-api/data" 9 | ) 10 | 11 | func makeBidResponse(br *openrtb.BidRequest, ads []*data.Ad) *openrtb.BidResponse { 12 | sb := makeSeatBid(ads) 13 | return &openrtb.BidResponse{ 14 | ID: br.ID, 15 | SeatBid: sb, 16 | Cur: consts.DefaultBidCur, 17 | } 18 | } 19 | 20 | func makeSeatBid(ads []*data.Ad) []openrtb.SeatBid { 21 | bid := make([]openrtb.Bid, len(ads)) 22 | for i, ad := range ads { 23 | bid[i] = *makeBid(ad) 24 | } 25 | return []openrtb.SeatBid{ 26 | openrtb.SeatBid{ 27 | Bid: bid, 28 | }, 29 | } 30 | } 31 | 32 | func makeBid(ad *data.Ad) *openrtb.Bid { 33 | return &openrtb.Bid{ 34 | ID: uuid.NewRandom().String(), 35 | ImpID: ad.ImpID, 36 | Price: ad.CalcBidPrice(), 37 | AdID: ad.AdID, 38 | CID: ad.CampaignID, 39 | CrID: ad.CreativeID, 40 | NURL: ad.NURL, 41 | IURL: ad.IURL, 42 | AdM: ad.AdM, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /click/handler.go: -------------------------------------------------------------------------------- 1 | package click 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/guregu/kami" 8 | "golang.org/x/net/context" 9 | 10 | "github.com/satoshi03/go-dsp-api/common/utils" 11 | "github.com/satoshi03/go-dsp-api/fluent" 12 | ) 13 | 14 | func clickHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) { 15 | // Parse win notice 16 | request := parseRequest(ctx, r) 17 | 18 | // Validate Request 19 | if err := request.validate(); err != nil { 20 | // Although error request is not valid, send ok response and log request 21 | log.Println(err) 22 | } 23 | 24 | // Send Response 25 | // Need Redirect? 26 | utils.WriteResponse(w, map[string]interface{}{"message": "ok"}, 200) 27 | 28 | // Send log 29 | fluent.Send(ctx, "fluent", "click", map[string]interface{}{ 30 | "WonPrice": request.WonPrice, 31 | "CreativeID": request.CreativeID, 32 | "ImpID": request.ImpID, 33 | }) 34 | } 35 | 36 | func InitHandler() { 37 | kami.Get("/v1/click/:crid", clickHandler) 38 | } 39 | -------------------------------------------------------------------------------- /click/request.go: -------------------------------------------------------------------------------- 1 | package click 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/guregu/kami" 9 | "golang.org/x/net/context" 10 | 11 | "github.com/satoshi03/go-dsp-api/common/errors" 12 | ) 13 | 14 | type Request struct { 15 | WonPrice float64 16 | CreativeID string 17 | ImpID string 18 | } 19 | 20 | func parseRequest(ctx context.Context, r *http.Request) *Request { 21 | wonPriceStr := r.FormValue("price") 22 | wonPrice, err := strconv.ParseFloat(wonPriceStr, 64) 23 | if err != nil { 24 | wonPrice = 0.0 25 | } 26 | 27 | impID := r.FormValue("impid") 28 | creativeID := kami.Param(ctx, "crid") 29 | 30 | return &Request{ 31 | WonPrice: wonPrice, 32 | CreativeID: creativeID, 33 | ImpID: impID, 34 | } 35 | } 36 | 37 | func (r *Request) validate() error { 38 | if r.WonPrice <= 0.0 { 39 | return errors.InvalidRequestParamError{"price", fmt.Sprintf("%f", r.WonPrice)} 40 | } 41 | 42 | if r.ImpID == "" { 43 | return errors.InvalidRequestParamError{"impid", r.ImpID} 44 | } 45 | 46 | if r.CreativeID == "" { 47 | return errors.InvalidRequestParamError{"crid", r.CreativeID} 48 | } 49 | // validation ok 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /command/create_mock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "github.com/garyburd/redigo/redis" 8 | "gopkg.in/vmihailenco/msgpack.v2" 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | type Config struct { 13 | IndexConfig IndexConfig `yaml:"index"` 14 | } 15 | 16 | type IndexConfig struct { 17 | BannerRegular Index `yaml:"banner"` 18 | BannerRectangle Index `yaml:"banner_rect"` 19 | Native Index `yaml:"native"` 20 | Video Index `yaml:"video"` 21 | } 22 | 23 | type Index []Ad 24 | 25 | type Ad struct { 26 | CampaignID string `yaml:"campaign_id"` 27 | CreativeID string `yaml:"creative_id"` 28 | Price float64 `yaml:"price"` 29 | AdID string `yaml:"ad_id"` 30 | NURL string `yaml:"nurl"` 31 | IURL string `yaml:"iurl"` 32 | AdM string `yaml:"adm"` 33 | Adomain map[string]interface{} `yaml:"adomain"` 34 | PeCPM float64 `yaml:"pecpm"` 35 | } 36 | 37 | func main() { 38 | 39 | c, err := redis.Dial("tcp", ":6379") 40 | if err != nil { 41 | fmt.Println(err) 42 | return 43 | } 44 | defer c.Close() 45 | 46 | buf, err := ioutil.ReadFile("data.yml") 47 | if err != nil { 48 | fmt.Println(err) 49 | return 50 | } 51 | 52 | var config Config 53 | err = yaml.Unmarshal(buf, &config) 54 | if err != nil { 55 | fmt.Println(err) 56 | return 57 | } 58 | 59 | if config.IndexConfig.BannerRegular != nil { 60 | b, err := msgpack.Marshal(config.IndexConfig.BannerRegular) 61 | if err != nil { 62 | fmt.Println(err) 63 | return 64 | } 65 | c.Do("SET", "index:banner", b) 66 | } 67 | 68 | if config.IndexConfig.BannerRectangle != nil { 69 | b, err := msgpack.Marshal(config.IndexConfig.BannerRectangle) 70 | if err != nil { 71 | fmt.Println(err) 72 | return 73 | } 74 | c.Do("SET", "index:banner_rect", b) 75 | } 76 | 77 | if config.IndexConfig.Native != nil { 78 | b, err := msgpack.Marshal(config.IndexConfig.Native) 79 | if err != nil { 80 | fmt.Println(err) 81 | return 82 | } 83 | c.Do("SET", "index:native", b) 84 | } 85 | 86 | if config.IndexConfig.Video != nil { 87 | b, err := msgpack.Marshal(config.IndexConfig.Video) 88 | if err != nil { 89 | fmt.Println(err) 90 | return 91 | } 92 | c.Do("SET", "index:native", b) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /command/data.yml: -------------------------------------------------------------------------------- 1 | index: 2 | banner: 3 | - campaign_id: '1234' 4 | creative_id: '12345' 5 | price: 200.0 6 | ad_id: '1234' 7 | nurl: 'http://test.noadnolife.com/v1/win/12345?impid=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}' 8 | iurl: 'http://test.noadnolife.com/img/12345.png' 9 | adm: 'Advertisement' 10 | pecpm: 250.0 11 | - campaign_id: '1235' 12 | creative_id: '12346' 13 | price: 155.0 14 | ad_id: '1235' 15 | nurl: 'http://test.noadnolife.com/v1/win/12346?price=${AUCTION_PRICE}' 16 | iurl: 'http://test.noadnolife.com/img/12346.png' 17 | adm: 'Advertisement' 18 | pecpm: 240.0 19 | banner_rect: 20 | - campaign_id: '1237' 21 | creative_id: '12347' 22 | price: 200.0 23 | ad_id: '1237' 24 | nurl: 'http://test.noadnolife.com/v1/win/12347?impid=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}' 25 | iurl: 'http://test.noadnolife.com/img/12347.png' 26 | adm: 'Advertisement' 27 | pecpm: 250.0 28 | - campaign_id: '1238' 29 | creative_id: '12348' 30 | price: 155.0 31 | ad_id: '1238' 32 | nurl: 'http://test.noadnolife.com/v1/win/12348?price=${AUCTION_PRICE}' 33 | iurl: 'http://test.noadnolife.com/img/12348.png' 34 | adm: 'Advertisement' 35 | pecpm: 240.0 36 | -------------------------------------------------------------------------------- /common/consts/consts.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | CtxRedisKey = `redis` 5 | CtxFluentKey = `fluent` 6 | 7 | // Currency type for bid price 8 | DefaultBidCur = `JPY` 9 | 10 | // Revenue ratio (0 <= x <= 1.0) 11 | RevenueRatio = 0.1 12 | 13 | // Regular banner image size 14 | BannerRegularWidth = 728 15 | BannerRegularHight = 90 16 | 17 | // Rectangle banner image size 18 | BannerRectangleWidth = 300 19 | BannerRectangleHight = 250 20 | ) 21 | -------------------------------------------------------------------------------- /common/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // For bidding validation 9 | InvalidCurError = errors.New("Bid currency is not valid") 10 | InvalidCreativeSizeError = errors.New("Creative size is invalid") 11 | InvalidViewTypeError = errors.New("View type is invalid") 12 | LowPriceError = errors.New("Bid Price is lower than floor price") 13 | 14 | // For redis access 15 | RedisKeyCreateError = errors.New("Failed to create redis key") 16 | ) 17 | 18 | type InvalidRequestParamError struct { 19 | Param string 20 | Value string 21 | } 22 | 23 | func (e InvalidRequestParamError) Error() string { 24 | return "Parameter is not valid. Param: " + e.Param + " Value: " + e.Value 25 | } 26 | 27 | type NoSupportError struct { 28 | NSField string 29 | } 30 | 31 | func (e NoSupportError) Error() string { 32 | return e.NSField + " is not supported" 33 | } 34 | -------------------------------------------------------------------------------- /common/utils/writer.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | func WriteResponse(w http.ResponseWriter, resp interface{}, code int) { 9 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 10 | w.Header().Set("x-openrtb-version", "2.3") 11 | w.WriteHeader(code) 12 | json.NewEncoder(w).Encode(resp) 13 | } 14 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | 6 | "gopkg.in/yaml.v2" 7 | ) 8 | 9 | type Config struct { 10 | Redis RedisConfig `yaml:"redis"` 11 | Fluent FluentConfig `yaml:"fluent"` 12 | } 13 | 14 | type RedisConfig struct { 15 | Host string `yaml:"host"` 16 | Port int `yaml:"port"` 17 | } 18 | 19 | type FluentConfig struct { 20 | Host string `yaml:"host"` 21 | Port int `yaml:"port"` 22 | } 23 | 24 | func Read() *Config { 25 | // Read config file 26 | buf, err := ioutil.ReadFile("config.yml") 27 | if err != nil { 28 | panic(err) 29 | } 30 | 31 | // Unmarshal yml 32 | var config Config 33 | err = yaml.Unmarshal(buf, &config) 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | return &config 39 | } 40 | -------------------------------------------------------------------------------- /config/config.yml.def: -------------------------------------------------------------------------------- 1 | redis: 2 | host: "127.0.0.1" 3 | port: 6379 4 | 5 | fluent: 6 | host: "127.0.0.1" 7 | port: 24224 8 | -------------------------------------------------------------------------------- /data/ad.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/satoshi03/go-dsp-api/common/consts" 5 | ) 6 | 7 | type Ad struct { 8 | ImpID string 9 | CampaignID string 10 | CreativeID string 11 | Price float64 12 | AdID string 13 | NURL string 14 | IURL string 15 | AdM string 16 | Adomain map[string]interface{} 17 | PeCPM float64 18 | } 19 | 20 | // Calculate bid price by considering revenue 21 | func (ad *Ad) CalcBidPrice() float64 { 22 | // Very simple implementation 23 | // For revenue maximization, need to find the bid price that can win by lowest price 24 | return ad.PeCPM * (1.0 - consts.RevenueRatio) 25 | } 26 | -------------------------------------------------------------------------------- /data/index.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/mxmCherry/openrtb" 5 | "golang.org/x/net/context" 6 | "gopkg.in/vmihailenco/msgpack.v2" 7 | 8 | "github.com/satoshi03/go-dsp-api/common/consts" 9 | "github.com/satoshi03/go-dsp-api/common/errors" 10 | "github.com/satoshi03/go-dsp-api/redis" 11 | ) 12 | 13 | type Index []Ad 14 | 15 | const ( 16 | KeyPrefix = `index` 17 | BannerRegular = `banner` 18 | BannerRectangle = `banner_rect` 19 | Video = `video` 20 | Native = `native` 21 | ) 22 | 23 | func getBannerDetailView(w, h uint64) (string, error) { 24 | // if size is empty, use default 25 | if w == 0 && h == 0 { 26 | return BannerRegular, nil 27 | } 28 | // In case of regular banner size 29 | if w == consts.BannerRegularWidth && h == consts.BannerRegularHight { 30 | return BannerRegular, nil 31 | } 32 | // In case of rectangle banner size 33 | if w == consts.BannerRectangleWidth && h == consts.BannerRectangleHight { 34 | return BannerRectangle, nil 35 | } 36 | // Creative size not match 37 | return "", errors.InvalidCreativeSizeError 38 | } 39 | 40 | func getView(imp *openrtb.Imp) (string, error) { 41 | switch { 42 | case imp.Banner != nil: 43 | return getBannerDetailView(imp.Banner.W, imp.Banner.H) 44 | case imp.Video != nil: 45 | return "", errors.NoSupportError{"video"} 46 | case imp.Native != nil: 47 | return "", errors.NoSupportError{"native"} 48 | } 49 | return "", errors.InvalidViewTypeError 50 | } 51 | 52 | func makeKey(imp *openrtb.Imp) (string, error) { 53 | viewType, err := getView(imp) 54 | if err != nil { 55 | return "", err 56 | } 57 | 58 | // : 59 | // e.g index:banner 60 | return KeyPrefix + ":" + viewType, nil 61 | } 62 | 63 | func GetIndex(ctx context.Context, imp *openrtb.Imp) (Index, error) { 64 | // Get key for getting value in redis 65 | key, err := makeKey(imp) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | // Get value from redis 71 | cli := redis.GetConn(ctx, consts.CtxRedisKey) 72 | value, _ := redis.GetCmd(cli, key) 73 | var out Index 74 | err = msgpack.Unmarshal([]byte(value), &out) 75 | if err != nil { 76 | // Failed to get valid value 77 | return nil, err 78 | } 79 | 80 | return out, nil 81 | } 82 | -------------------------------------------------------------------------------- /examples/request.mobile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "IxexyLDIIk", 3 | "imp": [ 4 | { 5 | "id": "1", 6 | "banner": { 7 | "w": 728, 8 | "h": 90, 9 | "pos": 1, 10 | "btype": [ 11 | 4 12 | ], 13 | "battr": [ 14 | 14 15 | ], 16 | "api": [ 17 | 3 18 | ] 19 | }, 20 | "instl": 0, 21 | "tagid": "agltb3B1Yi1pbmNyDQsSBFNpdGUY7fD0FAw", 22 | "bidfloor": 50 23 | } 24 | ], 25 | "app": { 26 | "id": "agltb3B1Yi1pbmNyDAsSA0FwcBiJkfIUDA", 27 | "name": "Yahoo Weather", 28 | "cat": [ 29 | "weather", 30 | "IAB15", 31 | "IAB15-10" 32 | ], 33 | "ver": "1.0.2", 34 | "bundle": "628677149", 35 | "publisher": { 36 | "id": "agltb3B1Yi1pbmNyDAsSA0FwcBiJkfTUCV", 37 | "name": "yahoo", 38 | "domain": "www.yahoo.com" 39 | }, 40 | "storeurl": "https://itunes.apple.com/id628677149" 41 | }, 42 | "device": { 43 | "dnt": 0, 44 | "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 6_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9A334 Safari/7534.48.3", 45 | "ip": "123.145.167.189", 46 | "geo": { 47 | "country": "USA", 48 | "lat": 35.012345, 49 | "lon": -115.12345, 50 | "city": "Los Angeles", 51 | "metro": "803", 52 | "region": "CA", 53 | "zip": "90049" 54 | }, 55 | "dpidsha1": "AA000DFE74168477C70D291f574D344790E0BB11", 56 | "dpidmd5": "AA003EABFB29E6F759F3BDAB34E50BB11", 57 | "carrier": "310-410", 58 | "language": "en", 59 | "make": "Apple", 60 | "model": "iPhone", 61 | "os": "iOS", 62 | "osv": "6.1", 63 | "js": 1, 64 | "connectiontype": 3, 65 | "devicetype": 1 66 | }, 67 | "user": { 68 | "id": "ffffffd5135596709273b3a1a07e466ea2bf4fff", 69 | "yob": 1984, 70 | "gender": "M" 71 | }, 72 | "at": 2, 73 | "bcat": [ 74 | "IAB25", 75 | "IAB7-39", 76 | "IAB8-18", 77 | "IAB8-5", 78 | "IAB9-9" 79 | ], 80 | "badv": [ 81 | "apple.com", 82 | "go-text.me", 83 | "heywire.com" 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /examples/request.pc.single.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "80ce30c53c16e6ede735f123ef6e32361bfc7b22", 3 | "imp": [ 4 | { 5 | "id": "1", 6 | "banner": { 7 | "h": 250, 8 | "w": 300, 9 | "pos": 0 10 | }, 11 | "bidfloor": 3 12 | } 13 | ], 14 | "site": { 15 | "id": "102855", 16 | "domain": "http://www.usabarfinder.com", 17 | "cat": ["IAB3-1"], 18 | "page": "http://eas.usabarfinder.com/eas?cu=13824;cre=mu;target=_blank", 19 | "publisher": { 20 | "id": "8953", 21 | "name": "local.com", 22 | "cat": ["IAB3-1"], 23 | "domain": "local.com" 24 | } 25 | }, 26 | "device": { 27 | "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/537.13 (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2", 28 | "ip": "123.145.167.*" 29 | }, 30 | "user": { 31 | "id": "55816b39711f9b5acf3b90e313ed29e51665623f" 32 | }, 33 | "at": 1, 34 | "cur": [ 35 | "JPY" 36 | ], 37 | "regs": { 38 | "coppa": 1 39 | }, 40 | "pmp": { 41 | "private_auction": 1, 42 | "deals": [ 43 | { 44 | "id": "DX-1985-010A", 45 | "bidfloor": 2.5, 46 | "at": 2 47 | } 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /examples/request.simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "IxexyLDIIk", 3 | "imp": [ 4 | { 5 | "id": "1", 6 | "banner": { 7 | "w": 728, 8 | "h": 90, 9 | "pos": 1, 10 | "btype": [ 11 | 4 12 | ], 13 | "battr": [ 14 | 14 15 | ], 16 | "api": [ 17 | 3 18 | ] 19 | }, 20 | "instl": 0, 21 | "tagid": "agltb3B1Yi1pbmNyDQsSBFNpdGUY7fD0FAw", 22 | "bidfloor": 100.0 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /fluent/fluent.go: -------------------------------------------------------------------------------- 1 | package fluent 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | flib "github.com/fluent/fluent-logger-golang/fluent" 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | func Open(ctx context.Context, host string, port int, key string) context.Context { 12 | // TODO: implement async run by go routine 13 | f, err := flib.New(flib.Config{FluentPort: port, FluentHost: host}) 14 | if err != nil { 15 | panic(err) 16 | } 17 | return context.WithValue(ctx, key, f) 18 | } 19 | 20 | func Send(ctx context.Context, key, tag string, data map[string]interface{}) { 21 | f := ctx.Value(key).(*flib.Fluent) 22 | data["created_at"] = time.Now().Format("2006-01-02 15:04:05 -0700") 23 | if err := f.Post(tag, data); err != nil { 24 | log.Println(err) 25 | } 26 | } 27 | 28 | func Close(ctx context.Context, key string) context.Context { 29 | f := ctx.Value(key).(*flib.Fluent) 30 | if err := f.Close(); err != nil { 31 | log.Println(err) 32 | } 33 | return context.WithValue(ctx, key, nil) 34 | } 35 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/satoshi03/go-dsp-api/bid" 5 | "github.com/satoshi03/go-dsp-api/click" 6 | "github.com/satoshi03/go-dsp-api/win" 7 | ) 8 | 9 | func init() { 10 | bid.InitHandler() 11 | win.InitHandler() 12 | click.InitHandler() 13 | } 14 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/guregu/kami" 7 | "golang.org/x/net/context" 8 | 9 | "github.com/satoshi03/go-dsp-api/common/consts" 10 | "github.com/satoshi03/go-dsp-api/config" 11 | "github.com/satoshi03/go-dsp-api/fluent" 12 | "github.com/satoshi03/go-dsp-api/redis" 13 | ) 14 | 15 | func main() { 16 | cpus := runtime.NumCPU() 17 | runtime.GOMAXPROCS(cpus) 18 | 19 | conf := config.Read() 20 | 21 | ctx := redis.Open(context.Background(), conf.Redis.Host, conf.Redis.Port, consts.CtxRedisKey) 22 | defer redis.Close(ctx, consts.CtxRedisKey) 23 | 24 | ctx = fluent.Open(ctx, conf.Fluent.Host, conf.Fluent.Port, consts.CtxFluentKey) 25 | defer fluent.Close(ctx, consts.CtxFluentKey) 26 | 27 | kami.Context = ctx 28 | kami.Serve() 29 | } 30 | -------------------------------------------------------------------------------- /redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | rlib "github.com/garyburd/redigo/redis" 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | func Open(ctx context.Context, host string, port int, key string) context.Context { 13 | pool := newPool(host, port) 14 | 15 | return context.WithValue(ctx, key, pool) 16 | } 17 | 18 | func GetConn(ctx context.Context, key string) rlib.Conn { 19 | pool := ctx.Value(key).(*rlib.Pool) 20 | return pool.Get() 21 | } 22 | 23 | func Close(ctx context.Context, key string) context.Context { 24 | redis := GetConn(ctx, key) 25 | if err := redis.Close(); err != nil { 26 | log.Println("failed to close redis server:", err) 27 | } 28 | 29 | return context.WithValue(ctx, key, nil) 30 | } 31 | 32 | func newPool(host string, port int) *rlib.Pool { 33 | return &rlib.Pool{ 34 | MaxIdle: 3, 35 | IdleTimeout: 240 * time.Second, 36 | Dial: func() (rlib.Conn, error) { 37 | c, err := rlib.Dial("tcp", fmt.Sprintf("%s:%d", host, port)) 38 | if err != nil { 39 | log.Println("failed to dial redis server:", err) 40 | return nil, err 41 | } 42 | return c, err 43 | }, 44 | TestOnBorrow: func(c rlib.Conn, t time.Time) error { 45 | _, err := c.Do("PING") 46 | log.Println("redis server connection error:", err) 47 | return err 48 | }, 49 | } 50 | } 51 | 52 | func GetCmd(cli rlib.Conn, key string) (string, error) { 53 | return rlib.String(cli.Do("GET", key)) 54 | } 55 | -------------------------------------------------------------------------------- /win/handler.go: -------------------------------------------------------------------------------- 1 | package win 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/guregu/kami" 8 | "golang.org/x/net/context" 9 | 10 | "github.com/satoshi03/go-dsp-api/common/utils" 11 | "github.com/satoshi03/go-dsp-api/fluent" 12 | ) 13 | 14 | func winHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) { 15 | // Parse win notice 16 | request := parseRequest(ctx, r) 17 | 18 | // Validate Request 19 | if err := request.validate(); err != nil { 20 | // Although error request is not valid, send ok response and log request 21 | log.Println(err) 22 | } 23 | 24 | // Send Response 25 | // No send Ad markup since it is included in bid response 26 | utils.WriteResponse(w, map[string]interface{}{"message": "ok"}, 200) 27 | 28 | // Send log 29 | fluent.Send(ctx, "fluent", "win", map[string]interface{}{ 30 | "WonPrice": request.WonPrice, 31 | "CreativeID": request.CreativeID, 32 | "ImpID": request.ImpID, 33 | }) 34 | } 35 | 36 | func InitHandler() { 37 | kami.Get("/v1/win/:crid", winHandler) 38 | } 39 | -------------------------------------------------------------------------------- /win/request.go: -------------------------------------------------------------------------------- 1 | package win 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/guregu/kami" 9 | "golang.org/x/net/context" 10 | 11 | "github.com/satoshi03/go-dsp-api/common/errors" 12 | ) 13 | 14 | type Request struct { 15 | WonPrice float64 16 | CreativeID string 17 | ImpID string 18 | } 19 | 20 | func parseRequest(ctx context.Context, r *http.Request) *Request { 21 | wonPriceStr := r.FormValue("price") 22 | wonPrice, err := strconv.ParseFloat(wonPriceStr, 64) 23 | if err != nil { 24 | wonPrice = 0.0 25 | } 26 | 27 | impID := r.FormValue("impid") 28 | creativeID := kami.Param(ctx, "crid") 29 | 30 | return &Request{ 31 | WonPrice: wonPrice, 32 | CreativeID: creativeID, 33 | ImpID: impID, 34 | } 35 | } 36 | 37 | func (r *Request) validate() error { 38 | if r.WonPrice <= 0.0 { 39 | return errors.InvalidRequestParamError{"price", fmt.Sprintf("%f", r.WonPrice)} 40 | } 41 | 42 | if r.ImpID == "" { 43 | return errors.InvalidRequestParamError{"impid", r.ImpID} 44 | } 45 | 46 | if r.CreativeID == "" { 47 | return errors.InvalidRequestParamError{"crid", r.CreativeID} 48 | } 49 | // validation ok 50 | return nil 51 | } 52 | --------------------------------------------------------------------------------