diff options
-rw-r--r-- | README | 7 | ||||
-rw-r--r-- | TODO | 3 | ||||
-rw-r--r-- | bommom.go | 2 | ||||
-rw-r--r-- | core.go | 8 | ||||
-rw-r--r-- | octopart.go | 129 | ||||
-rw-r--r-- | octopart_test.go | 9 | ||||
-rw-r--r-- | serve.go | 116 | ||||
-rw-r--r-- | templates/bom_upload.html | 29 |
8 files changed, 284 insertions, 19 deletions
@@ -10,13 +10,18 @@ BomMom: A web publishing/wiki system for electronics Bill of Materials (BOM) -A work in progress as of April 2012. Written in golang. +VAPORWARE ALERT! A work in progress as of April 2012. + +Written in golang. ### Instructions Install golang compiler and run `go build` in this directory, then run the `bommom` command to list available commands and options. +Run ``./bommom -port 7777 serve`` to start a server on local port 7777; by +default listens on all interfaces. + ### Basic Features - command line tools for managing part list files @@ -0,0 +1,3 @@ +- crude octopart integration (prices, availability) +- basic view-only HTML5 skin, with charts etc +- deployment scheme @@ -175,7 +175,7 @@ func initCmd() { // dummy BomMeta already exists? return } - b := makeTestBom() + _, b := makeTestBom() b.Version = "v001" bm = &BomMeta{Name: "gizmo", Owner: "common", @@ -30,6 +30,7 @@ type LineItem struct { Category string `json:"category"` // hierarchy as comma seperated list Elements []string `json:"elements"` Offers []Offer `json:"offers"` + AggregateInfo map[string]string `json:"miscinfo"` } func (li *LineItem) Id() string { @@ -101,7 +102,7 @@ func (bm *BomMeta) Validate() error { } // ---------- testing -func makeTestBom() *Bom { +func makeTestBom() (*BomMeta, *Bom) { op1 := OfferPrice{Currency: "usd", Price: 1.0, MinQty: 1} op2 := OfferPrice{Currency: "usd", Price: 0.8, MinQty: 100} o := Offer{Sku: "A123", Distributor: "Acme", Prices: []OfferPrice{op1, op2}} @@ -114,5 +115,8 @@ func makeTestBom() *Bom { //li.AddOffer(o) b := NewBom("test01") b.AddLineItem(&li) - return b + b.AddLineItem(&li) + b.AddLineItem(&li) + bm := &BomMeta{Name: "Some Bom", Owner: "Some Owner", Description: "This is such a thing!", HeadVersion: b.Version, Homepage: "http://bommom.com", IsPublicView: true, IsPublicEdit: false} + return bm, b } diff --git a/octopart.go b/octopart.go index a1f14df..367bc99 100644 --- a/octopart.go +++ b/octopart.go @@ -2,6 +2,11 @@ package main import ( "net/http" + "net/url" + "encoding/json" + "bytes" + "log" + //"io/ioutil" ) /* @@ -15,34 +20,140 @@ type OctopartClient struct { ApiKey string RemoteHost string client *http.Client + infoCache map[string]interface{} } func NewOctopartClient(apikey string) *OctopartClient { oc := &OctopartClient{ApiKey: apikey, - RemoteHost: "https://www.octopart.com"} + RemoteHost: "https://octopart.com"} oc.client = &http.Client{} + oc.infoCache = make(map[string]interface{}) return oc } func openPricingSource() { + // TODO: pass through octopart API key here pricingSource = NewOctopartClient("") } -func (*oc OctopartClient) apiCall(method string, params map[string]string) (map[string]interface, error) { +func (oc *OctopartClient) apiCall(method string, params map[string]string) (map[string]interface{}, error) { paramString := "?apikey=" + oc.ApiKey + // TODO: assert clean-ness of params + // TODO: use url.Values instead... for key := range params { - paramString += "&" + key + "=" + params[key] + paramString += "&" + url.QueryEscape(key) + "=" + url.QueryEscape(params[key]) } - resp, err := oc.client.Get(oc.RemoteHost + "/api/v2/" + method) - - // resp as json, or interpret as error - return + paramStringUnescaped, _ := url.QueryUnescape(paramString) // TODO: err + log.Println("Fetching: " + oc.RemoteHost + "/api/v2/" + method + paramStringUnescaped) + resp, err := oc.client.Get(oc.RemoteHost + "/api/v2/" + method + paramString) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, Error("Octopart API call error: " + resp.Status) + } + result := make(map[string]interface{}) + defer resp.Body.Close() + //body, err := ioutil.ReadAll(resp.Body) + //if err != nil { + // return nil, err + //} + //body = append(body, '\n') + //dec := json.NewDecoder(bytes.NewReader(body)) + dec := json.NewDecoder(resp.Body) + if err = dec.Decode(&result); err != nil { + return nil, err + } + return result, nil } -func (*oc OctopartClient) GetMarketInfo(mpn, manufacturer string) (map[string]interface, error) { +// this method doesn't check internal cache, but it does append to it +func (oc *OctopartClient) bomApiCall(manufacturers, mpns []string) ([]map[string]interface{}, error) { + // TODO: check len(mpns) == len(manufacturers) + queryList := make([]map[string]string, len(mpns)) + listItem := make(map[string]string) + for i, _ := range mpns { + listItem = make(map[string]string) + listItem["mpn_or_sku"] = mpns[i] + listItem["manufacturer"] = manufacturers[i] + listItem["limit"] = "1" + listItem["reference"] = manufacturers[i] + "|" + mpns[i] + queryList[i] = listItem + } + linesBuffer := new(bytes.Buffer) + enc := json.NewEncoder(linesBuffer) + if err := enc.Encode(queryList); err != nil { + return nil, err + } + + response, err := oc.apiCall("bom/match", map[string]string{"lines": linesBuffer.String()}) + if err != nil { + return nil, err + } + // TODO: just grabbing first result for now; user can make better specification later + ret := make([]map[string]interface{}, len(mpns)) + for i, rawresult := range response["results"].([]interface{}) { + result := rawresult.(map[string]interface{}) + hits := int(result["hits"].(float64)) + reference := result["reference"].(string) + if hits == 0 { + ret[i] = nil + oc.infoCache[reference] = nil + } else { + ret[i] = result["items"].([]interface{})[0].(map[string]interface{}) + oc.infoCache[reference] = ret[i] + } + } + return ret, nil } -func (*oc OctopartClient) GetPricing(method string, params map[string]string) (map[string]interface, error) { +func (oc *OctopartClient) GetMarketInfo(manufacturers, mpns []string) ([]interface{}, error) { + if len(mpns) < 1 { + return nil, Error("no mpns strings passed in") + } + if len(mpns) != len(manufacturers) { + return nil, Error("number of mpns doesn't match number of manufacturers") + } + if len(mpns) > 100 { + return nil, Error("can't handle more than 100 queries at a time (yet)") + } + mpnToQuery := make([]string, 0) + manufacturersToQuery := make([]string, 0) + queryHash := "" + // check for queryHashes in internal cache + for i, _ := range mpns { + queryHash = manufacturers[i] + "|" + mpns[i] + if _, hasKey := oc.infoCache[queryHash]; hasKey != true { + manufacturersToQuery = append(manufacturersToQuery, manufacturers[i]) + mpnToQuery = append(mpnToQuery, mpns[i]) + } + } + // if necessary, fetch missing queryHashes to internal cache + if len(mpnToQuery) > 0 { + if _, err := oc.bomApiCall(manufacturersToQuery, mpnToQuery); err != nil { + return nil, err + } + } + // construct list of return info + result := make([]interface{}, len(mpns)) + for i, _ := range mpns { + queryHash = manufacturers[i] + "|" + mpns[i] + value, hasKey := oc.infoCache[queryHash] + if hasKey != true { + return nil, Error("key should be in cache, but isn't: " + queryHash) + } + result[i] = value + } + return result, nil +} +func (oc *OctopartClient) GetReducedPricing(mpn, manufacturer string) (interface{}, error) { + marketInfo, err := oc.GetMarketInfo([]string{mpn}, []string{manufacturer}) + if err != nil { + return nil, err + } + // reduce marketInfo to pricing + return marketInfo[0], nil } + diff --git a/octopart_test.go b/octopart_test.go index 75953ee..2e0e295 100644 --- a/octopart_test.go +++ b/octopart_test.go @@ -42,4 +42,11 @@ func TestGetMarketInfo(t *testing.T) { } } -//TODO? TestGetPricing +func TestAttachInfo(t *testing.T) { + b := makeTestBom() + bm := &BomMeta{} + oc := NewOctopartClient("") + oc.AttachMarketInfo(oc) + DumpBomAsText(bm, b, +} + @@ -6,25 +6,31 @@ import ( "log" "net/http" "regexp" + "path/filepath" + "time" ) var ( - tmplHome, tmplView, tmplUser, tmplBomView *template.Template + tmplHome, tmplView, tmplUser, tmplBomView, tmplBomUpload *template.Template ) func baseHandler(w http.ResponseWriter, r *http.Request) { var err error log.Printf("serving %s\n", r.URL.Path) - bomUrlPattern := regexp.MustCompile("^/([a-zA-Z][a-zA-Z0-9_]*)/([a-zA-Z][a-zA-Z0-9_]*)(/.*)$") + bomUrlPattern := regexp.MustCompile("^/([a-zA-Z][a-zA-Z0-9_]*)/([a-zA-Z][a-zA-Z0-9_]*)/$") + bomUploadUrlPattern := regexp.MustCompile("^/([a-zA-Z][a-zA-Z0-9_]*)/([a-zA-Z][a-zA-Z0-9_]*)/_upload/$") userUrlPattern := regexp.MustCompile("^/([a-zA-Z][a-zA-Z0-9_]*)/$") switch { case r.URL.Path == "/": err = homeController(w, r) + case bomUploadUrlPattern.MatchString(r.URL.Path): + match := bomUploadUrlPattern.FindStringSubmatch(r.URL.Path) + err = bomUploadController(w, r, match[1], match[2]) case bomUrlPattern.MatchString(r.URL.Path): match := bomUrlPattern.FindStringSubmatch(r.URL.Path) - err = bomController(w, r, match[1], match[2], match[3]) + err = bomController(w, r, match[1], match[2]) case userUrlPattern.MatchString(r.URL.Path): match := userUrlPattern.FindStringSubmatch(r.URL.Path) err = userController(w, r, match[1], "") @@ -74,7 +80,7 @@ func userController(w http.ResponseWriter, r *http.Request, user, extra string) return } -func bomController(w http.ResponseWriter, r *http.Request, user, name, extra string) (err error) { +func bomController(w http.ResponseWriter, r *http.Request, user, name string) (err error) { if !isShortName(user) { http.Error(w, "invalid username: "+user, 400) return @@ -86,12 +92,111 @@ func bomController(w http.ResponseWriter, r *http.Request, user, name, extra str context := make(map[string]interface{}) context["BomMeta"], context["Bom"], err = bomstore.GetHead(ShortName(user), ShortName(name)) if err != nil { - return + http.Error(w, "404 couldn't open bom: " + user + "/" + name, 404) + return nil } err = tmplBomView.Execute(w, context) return } +func bomUploadController(w http.ResponseWriter, r *http.Request, user, name string) (err error) { + + if !isShortName(user) { + http.Error(w, "invalid username: "+user, 400) + return + } + if !isShortName(name) { + http.Error(w, "invalid bom name: "+name, 400) + return + } + context := make(map[string]interface{}) + context["user"] = ShortName(user) + context["name"] = ShortName(name) + context["BomMeta"], context["Bom"], err = bomstore.GetHead(ShortName(user), ShortName(name)) + + switch r.Method { + case "POST": + + + err := r.ParseMultipartForm(1024*1024*2) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), 400) + return nil + } + file, fileheader, err := r.FormFile("bomfile") + if err != nil { + log.Println(err) + context["error"] = "bomfile was nil!" + err = tmplBomUpload.Execute(w, context) + return err + } + if file == nil { + log.Println("bomfile was nil") + context["error"] = "bomfile was nil!" + err = tmplBomUpload.Execute(w, context) + return err + } + versionStr := r.FormValue("version") + if len(versionStr) == 0 || isShortName(versionStr) == false { + context["error"] = "Version must be specified and a ShortName!" + context["version"] = versionStr + err = tmplBomUpload.Execute(w, context) + return err + } + + //contentType := fileheader.Header["Content-Type"][0] + var b *Bom + var bm *BomMeta + + switch filepath.Ext(fileheader.Filename) { + case ".json": + bm, b, err = LoadBomFromJSON(file) + if err != nil { + context["error"] = "Problem loading JSON file" + err = tmplBomUpload.Execute(w, context) + return err + } + case ".csv": + b, err = LoadBomFromCSV(file) + bm = &BomMeta{} + if err != nil { + context["error"] = "Problem loading XML file" + err = tmplBomUpload.Execute(w, context) + return err + } + case ".xml": + bm, b, err = LoadBomFromXML(file) + if err != nil { + context["error"] = "Problem loading XML file" + err = tmplBomUpload.Execute(w, context) + return err + } + default: + context["error"] = "Unknown file type: " + string(fileheader.Filename) + err = tmplBomUpload.Execute(w, context) + return err + } + bm.Owner = user + bm.Name = name + b.Progeny = "File uploaded from " + fileheader.Filename + b.Created = time.Now() + b.Version = string(versionStr) + if err := bomstore.Persist(bm, b, ShortName(versionStr)); err != nil { + context["error"] = "Problem saving to datastore: " + err.Error() + err = tmplBomUpload.Execute(w, context) + } + http.Redirect(w, r, "//" + user + "/" + name + "/", 302) + case "GET": + err = tmplBomUpload.Execute(w, context) + default: + http.Error(w, "bad method", 405) + return nil + } + return +} + + func serveCmd() { var err error @@ -100,6 +205,7 @@ func serveCmd() { tmplHome = template.Must(template.ParseFiles(*templatePath+"/home.html", baseTmplPath)) tmplUser = template.Must(template.ParseFiles(*templatePath+"/user.html", baseTmplPath)) tmplBomView = template.Must(template.ParseFiles(*templatePath+"/bom_view.html", baseTmplPath)) + tmplBomUpload = template.Must(template.ParseFiles(*templatePath+"/bom_upload.html", baseTmplPath)) if err != nil { log.Fatal(err) } diff --git a/templates/bom_upload.html b/templates/bom_upload.html new file mode 100644 index 0000000..1d03963 --- /dev/null +++ b/templates/bom_upload.html @@ -0,0 +1,29 @@ +{{ template "HEADER" }} +{{ if .BomMeta }} + +<h1>{{ .BomMeta.Name }} is a bom.</h1> +<b>Owner: </b>{{ .BomMeta.Owner }}<br> +{{ if .BomMeta.Homepage }}<b>Homepage: </b>{{ .BomMeta.Homepage }}<br>{{ end }} +{{ if .BomMeta.Description }}<b>Description: </b>{{ .BomMeta.Description }}<br>{{ end }} +<b>Version: </b>{{ .BomMeta.HeadVersion }} (at head)<br> +<b>Created: </b>{{ .Bom.Created }}<br> +{{ if .Bom.Progeny}}<b>Source: </b>{{ .Bom.Progeny }}<br>{{ end }} +<hr> + +{{ else }} + +<h1>{{ .name }} could be a bom.</h1> +<b>Owner: </b>{{ .user }}<br> +<hr> + +{{ end }} + +{{ if .error }}<h3 style="color:red;">{{ .error }}</h3>{{ end }} +<form enctype="multipart/form-data" method="POST" target="."> +<b>Version: </b><input name="version" type="text" value="{{ .version }}"></input><br> +<b>Created: </b>now!<br> +<input type="file" name="bomfile" accept="application/json,application/xml,text/csv"></input><br> +<input type="submit" value="Upload"></input> +</form> + +{{ template "FOOTER" }} |