package protocol import ( "git.smesh.lol/nostr/pkg/lol/errorf" "git.smesh.lol/nostr/pkg/text" ) // ShippingZone is one shipping option on a stall or product: // {"id": "eu", "cost": 5.00}. Cost is kept as written: it is a JSON number and // reformatting it would change the stored content. type ShippingZone struct { ID string Cost string } // SpecPair is one product spec. Physical music links its provenance through // specs whose key is "a": ["a", "32216::"] inside the specs // array, not as a top-level tag. type SpecPair struct { Key string Value string } // Stall is the NIP-15 stall (kind 30017): a named collection of products with // a currency and shipping zones. The payload is JSON in content, not tags. type Stall struct { ID string Name string Currency string Shipping []ShippingZone Content string // raw content when parsed; JSON() renders from the fields Extra []string // unknown top-level field names, carried so a rewrite does not drop them } // Product is the NIP-15 product (kind 30018): one item for sale. Its content is // JSON as well; the tag list carries only the d tag. type Product struct { ID string StallID string Name string Description string Images []string Currency string Price string Quantity string // "" means unlimited or made to order Specs []SpecPair Shipping []ShippingZone Content string Extra []string } // EditionRefs lists the edition and album addresses a product links to through // its specs, which is how a physical release carries its provenance. func (p *Product) EditionRefs() (refs []*Address) { for i := range p.Specs { if p.Specs[i].Key != "a" { continue } a, err := ParseAddress(p.Specs[i].Value) if err != nil { continue } refs = push(refs, a) } return } // Copy returns a deep-enough copy of a product's scalar state, for a caller // that wants to modify one without touching the original. func (p *Product) Copy() (c *Product) { c = &Product{ ID: p.ID, StallID: p.StallID, Name: p.Name, Description: p.Description, Currency: p.Currency, Price: p.Price, Quantity: p.Quantity, Content: p.Content, } for i := range p.Images { c.Images = push(c.Images, p.Images[i]) } for i := range p.Specs { c.Specs = push(c.Specs, p.Specs[i]) } for i := range p.Shipping { c.Shipping = push(c.Shipping, p.Shipping[i]) } for i := range p.Extra { c.Extra = push(c.Extra, p.Extra[i]) } return } // Validation for the NIP-15 payloads is about the fields the checkout needs, // not about a required-tag list: the documents define none. func (p *Product) Validate() (err error) { if p == nil { err = errorf.E("nil product") return } if p.ID == "" { err = errorf.E("product: no id") return } if p.StallID == "" { err = errorf.E("product: no stall_id") return } if p.Name == "" { err = errorf.E("product: no name") return } if p.Currency == "" { err = errorf.E("product: no currency") return } if p.Price == "" { err = errorf.E("product: no price") return } for i := range p.Specs { if p.Specs[i].Key == "" { err = errorf.E("product: spec %d has no key", i) return } } return } func (s *Stall) Validate() (err error) { if s == nil { err = errorf.E("nil stall") return } if s.ID == "" { err = errorf.E("stall: no id") return } if s.Name == "" { err = errorf.E("stall: no name") return } if s.Currency == "" { err = errorf.E("stall: no currency") return } return } // JSON renders the stall content in the NIP-15 shape. func (s *Stall) JSON() (out string) { out = "{\"id\":" | jsonString(s.ID) | ",\"name\":" | jsonString(s.Name) | ",\"currency\":" | jsonString(s.Currency) | ",\"shipping\":" | shippingJSON(s.Shipping) | "}" return } // JSON renders the product content in the NIP-15 shape the examples show: // a flat object with the camel/snake keys, specs as an array of two-element // arrays, shipping as an array of objects. func (p *Product) JSON() (out string) { out = "{\"id\":" | jsonString(p.ID) | ",\"stall_id\":" | jsonString(p.StallID) | ",\"name\":" | jsonString(p.Name) | ",\"description\":" | jsonString(p.Description) | ",\"images\":" | stringArrayJSON(p.Images) | ",\"currency\":" | jsonString(p.Currency) | ",\"price\":" | numberOrString(p.Price) if p.Quantity != "" { out = out | ",\"quantity\":" | numberOrString(p.Quantity) } else { out = out | ",\"quantity\":null" } out = out | ",\"specs\":" | specsJSON(p.Specs) | ",\"shipping\":" | shippingJSON(p.Shipping) | "}" return } func specsJSON(specs []SpecPair) (out string) { out = "[" for i := range specs { if i > 0 { out = out | "," } out = out | "[" | jsonString(specs[i].Key) | "," | jsonString(specs[i].Value) | "]" } return out | "]" } func shippingJSON(zones []ShippingZone) (out string) { out = "[" for i := range zones { if i > 0 { out = out | "," } out = out | "{\"id\":" | jsonString(zones[i].ID) | ",\"cost\":" | numberOrString(zones[i].Cost) | "}" } return out | "]" } func stringArrayJSON(xs []string) (out string) { out = "[" for i := range xs { if i > 0 { out = out | "," } out = out | jsonString(xs[i]) } return out | "]" } // numberOrString writes a numeric value bare when it is one and quotes it // otherwise. The examples carry prices as JSON numbers and quantities as // numbers, but the wire only requires that a reader can parse it. func numberOrString(s string) (out string) { if s == "" { return "null" } if isDecimal(s) { return s } return jsonString(s) } // jsonString quotes a value for JSON. func jsonString(s string) (out string) { out = text.AppendQuote(nil, s, text.NostrEscape) return } // ParseStall reads a NIP-15 stall content object. The raw content is kept: a // relay that stores the event does not need it re-rendered. func ParseStall(content string) (s *Stall, err error) { if !isJSONObject(content) { err = errorf.E("stall content is not a JSON object") return } s = &Stall{Content: content} s.ID = jsonFieldString(content, "id") s.Name = jsonFieldString(content, "name") s.Currency = jsonFieldString(content, "currency") s.Shipping = parseShipping(jsonFieldRaw(content, "shipping")) return } func ParseProduct(content string) (p *Product, err error) { if !isJSONObject(content) { err = errorf.E("product content is not a JSON object") return } p = &Product{Content: content} p.ID = jsonFieldString(content, "id") p.StallID = jsonFieldString(content, "stall_id") p.Name = jsonFieldString(content, "name") p.Description = jsonFieldString(content, "description") p.Images = parseStringArray(jsonFieldRaw(content, "images")) p.Currency = jsonFieldString(content, "currency") p.Price = jsonFieldRaw(content, "price") p.Quantity = jsonFieldRaw(content, "quantity") if p.Quantity == "null" { p.Quantity = "" } p.Specs = parseSpecs(jsonFieldRaw(content, "specs")) p.Shipping = parseShipping(jsonFieldRaw(content, "shipping")) return } // Listing is the NIP-99 classified listing (kind 30402, draft 30403): a // service offered or wanted, or a one-off resale. A resale of physical music // carries the edition address for provenance. type Listing struct { D string Title string Summary string PublishedAt string Location string Geohash string Price *Price Hashtags []string Image string Status string Edition *Address // resale provenance Content string Extra [][]string } func (l *Listing) Tags() (tags [][]string) { tags = appendKV(nil, TagD, l.D) tags = appendKV(tags, TagTitle, l.Title) tags = appendKV(tags, TagSummary, l.Summary) tags = appendKV(tags, TagPublishedAt, l.PublishedAt) tags = appendKV(tags, TagLocation, l.Location) tags = appendKV(tags, TagG, l.Geohash) tags = appendTag(tags, l.Price.Tag()) for i := range l.Hashtags { tags = appendKV(tags, TagT, l.Hashtags[i]) } tags = appendKV(tags, TagImage, l.Image) tags = appendKV(tags, TagStatus, l.Status) tags = appendTag(tags, l.Edition.Tag()) tags = appendTags(tags, l.Extra) return } func ParseListing(tags [][]string, content string) (l *Listing, err error) { l = &Listing{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagD: l.D = elem(tg, 1) case TagTitle: l.Title = elem(tg, 1) case TagSummary: l.Summary = elem(tg, 1) case TagPublishedAt: l.PublishedAt = elem(tg, 1) case TagLocation: l.Location = elem(tg, 1) case TagG: l.Geohash = elem(tg, 1) case TagPrice: l.Price = parsePrice(tg) case TagT: l.Hashtags = push(l.Hashtags, elem(tg, 1)) case TagImage: l.Image = elem(tg, 1) case TagStatus: l.Status = elem(tg, 1) case TagA: a, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } l.Edition = a default: l.Extra = appendTag(l.Extra, tg) } } return } func (l *Listing) Validate() (err error) { if l == nil { err = errorf.E("nil listing") return } if l.D == "" { err = errorf.E("listing: empty d tag") return } if l.Title == "" { err = errorf.E("listing: no title") return } if l.Edition != nil && l.Edition.Kind != KindEdition { err = errorf.E("listing: provenance reference is kind %d, want %d", l.Edition.Kind, KindEdition) return } switch l.Status { case "", StatusActive: default: // The documents name only "active" and say a listing can be filled or // closed; a closed value is not specified, so it is carried. } return } // --- a tiny JSON reader for the NIP-15 payloads --- // // Moxie has no `any`, so there is no general JSON value type to build on. The // NIP-15 payloads are a fixed shape, so the reader answers the questions that // shape needs: a top-level field's raw value, a string field, an array of // strings, an array of two-element arrays, an array of {id, cost} objects. func isJSONObject(s string) (ok bool) { i := jsonSkipWS(s, 0) return i >= 0 && i < len(s) && s[i] == '{' } // jsonSkipWS returns the index of the next non-whitespace byte, or -1. func jsonSkipWS(s string, i int32) (j int32) { for ; i < len(s); i++ { switch s[i] { case ' ', '\t', '\n', '\r': continue } return i } return -1 } // jsonFieldRaw finds a top-level field's value and returns it verbatim (for a // scalar) or its text including braces (for an array or object). It returns "" // when the field is absent, and "null" for an explicit null. func jsonFieldRaw(obj, key string) (out string) { i := jsonSkipWS(obj, 0) if i < 0 || obj[i] != '{' { return "" } i++ for i < len(obj) { i = jsonSkipWS(obj, i) if i < 0 || i >= len(obj) { return "" } if obj[i] == '}' { return "" } if obj[i] == ',' { i++ continue } if obj[i] != '"' { return "" } name, next, err := jsonReadString(obj, i) if err != nil { return "" } i = jsonSkipWS(obj, next) if i < 0 || obj[i] != ':' { return "" } i = jsonSkipWS(obj, i+1) if i < 0 { return "" } end, verr := jsonSkipValue(obj, i) if verr != nil { return "" } if name == key { return obj[i:end] } i = end } return "" } func jsonFieldString(obj, key string) (out string) { raw := jsonFieldRaw(obj, key) if raw == "" || raw == "null" { return "" } if raw[0] != '"' { return "" } v, _, err := jsonReadString(raw, 0) if err != nil { return "" } return v } // jsonReadString reads a quoted string starting at i and returns the decoded // value plus the index after the closing quote. func jsonReadString(s string, i int32) (val string, next int32, err error) { if i >= len(s) || s[i] != '"' { err = errorf.E("json: expected a string") return } content, rem, uerr := text.UnmarshalQuoted(s[i:]) if uerr != nil { err = uerr return } val = string(content) next = i + (int32(len(s[i:])) - int32(len(rem))) return } // jsonSkipValue returns the index just past the value starting at i, walking // nested arrays and objects. func jsonSkipValue(s string, i int32) (next int32, err error) { if i >= len(s) { err = errorf.E("json: value expected") return } switch s[i] { case '"': _, n, serr := jsonReadString(s, i) if serr != nil { err = serr return } return n, nil case '[', '{': open := s[i] closeB := byte(']') if open == '{' { closeB = '}' } depth := int32(0) for k := i; k < len(s); k++ { if s[k] == '"' { _, n, serr := jsonReadString(s, k) if serr != nil { err = serr return } k = n - 1 continue } if s[k] == open { depth++ continue } if s[k] == closeB { depth-- if depth == 0 { return k + 1, nil } } } err = errorf.E("json: unterminated container") return } // A number, true, false or null: run to the next delimiter. j := i for ; j < len(s); j++ { switch s[j] { case ',', '}', ']': return jsonTrimRight(s, i, j), nil } } return j, nil } func jsonTrimRight(s string, from, to int32) (end int32) { for to > from { c := s[to-1] if c == ' ' || c == '\t' || c == '\n' || c == '\r' { to-- continue } break } return to } // jsonElements splits a container's top-level elements, returning each // element's raw text. func jsonElements(s string) (out []string) { i := jsonSkipWS(s, 0) if i < 0 || (s[i] != '[' && s[i] != '{') { return nil } closing := byte(']') if s[i] == '{' { closing = '}' } i++ for i < len(s) { i = jsonSkipWS(s, i) if i < 0 || i >= len(s) { return } if s[i] == closing { return } if s[i] == ',' { i++ continue } end, err := jsonSkipValue(s, i) if err != nil { return } out = push(out, s[i:end]) i = end } return } func parseStringArray(raw string) (out []string) { if raw == "" || raw == "null" { return nil } els := jsonElements(raw) out = []string{:0:int32(len(els))} for i := range els { if len(els[i]) > 0 && els[i][0] == '"' { v, _, err := jsonReadString(els[i], 0) if err == nil { out = push(out, v) } } } return } func parseSpecs(raw string) (out []SpecPair) { if raw == "" || raw == "null" { return nil } els := jsonElements(raw) for i := range els { inner := jsonElements(els[i]) if len(inner) < 2 { continue } k, _, err := jsonReadString(inner[0], 0) if err != nil { continue } v, _, verr := jsonReadString(inner[1], 0) if verr != nil { continue } out = push(out, SpecPair{Key: k, Value: v}) } return } func parseShipping(raw string) (out []ShippingZone) { if raw == "" || raw == "null" { return nil } els := jsonElements(raw) for i := range els { id := jsonFieldString(els[i], "id") cost := jsonFieldRaw(els[i], "cost") if id == "" { continue } out = push(out, ShippingZone{ID: id, Cost: cost}) } return }