commerce.mx raw

   1  package protocol
   2  
   3  import (
   4  	"git.smesh.lol/nostr/pkg/lol/errorf"
   5  	"git.smesh.lol/nostr/pkg/text"
   6  )
   7  
   8  // ShippingZone is one shipping option on a stall or product:
   9  // {"id": "eu", "cost": 5.00}. Cost is kept as written: it is a JSON number and
  10  // reformatting it would change the stored content.
  11  type ShippingZone struct {
  12  	ID   string
  13  	Cost string
  14  }
  15  
  16  // SpecPair is one product spec. Physical music links its provenance through
  17  // specs whose key is "a": ["a", "32216:<pubkey>:<edition-d>"] inside the specs
  18  // array, not as a top-level tag.
  19  type SpecPair struct {
  20  	Key   string
  21  	Value string
  22  }
  23  
  24  // Stall is the NIP-15 stall (kind 30017): a named collection of products with
  25  // a currency and shipping zones. The payload is JSON in content, not tags.
  26  type Stall struct {
  27  	ID       string
  28  	Name     string
  29  	Currency string
  30  	Shipping []ShippingZone
  31  	Content  string // raw content when parsed; JSON() renders from the fields
  32  	Extra    []string // unknown top-level field names, carried so a rewrite does not drop them
  33  }
  34  
  35  // Product is the NIP-15 product (kind 30018): one item for sale. Its content is
  36  // JSON as well; the tag list carries only the d tag.
  37  type Product struct {
  38  	ID          string
  39  	StallID     string
  40  	Name        string
  41  	Description string
  42  	Images      []string
  43  	Currency    string
  44  	Price       string
  45  	Quantity    string // "" means unlimited or made to order
  46  	Specs       []SpecPair
  47  	Shipping    []ShippingZone
  48  	Content     string
  49  	Extra       []string
  50  }
  51  
  52  // EditionRefs lists the edition and album addresses a product links to through
  53  // its specs, which is how a physical release carries its provenance.
  54  func (p *Product) EditionRefs() (refs []*Address) {
  55  	for i := range p.Specs {
  56  		if p.Specs[i].Key != "a" {
  57  			continue
  58  		}
  59  		a, err := ParseAddress(p.Specs[i].Value)
  60  		if err != nil {
  61  			continue
  62  		}
  63  		refs = push(refs, a)
  64  	}
  65  	return
  66  }
  67  
  68  // Copy returns a deep-enough copy of a product's scalar state, for a caller
  69  // that wants to modify one without touching the original.
  70  func (p *Product) Copy() (c *Product) {
  71  	c = &Product{
  72  		ID: p.ID, StallID: p.StallID, Name: p.Name, Description: p.Description,
  73  		Currency: p.Currency, Price: p.Price, Quantity: p.Quantity, Content: p.Content,
  74  	}
  75  	for i := range p.Images {
  76  		c.Images = push(c.Images, p.Images[i])
  77  	}
  78  	for i := range p.Specs {
  79  		c.Specs = push(c.Specs, p.Specs[i])
  80  	}
  81  	for i := range p.Shipping {
  82  		c.Shipping = push(c.Shipping, p.Shipping[i])
  83  	}
  84  	for i := range p.Extra {
  85  		c.Extra = push(c.Extra, p.Extra[i])
  86  	}
  87  	return
  88  }
  89  
  90  // Validation for the NIP-15 payloads is about the fields the checkout needs,
  91  // not about a required-tag list: the documents define none.
  92  func (p *Product) Validate() (err error) {
  93  	if p == nil {
  94  		err = errorf.E("nil product")
  95  		return
  96  	}
  97  	if p.ID == "" {
  98  		err = errorf.E("product: no id")
  99  		return
 100  	}
 101  	if p.StallID == "" {
 102  		err = errorf.E("product: no stall_id")
 103  		return
 104  	}
 105  	if p.Name == "" {
 106  		err = errorf.E("product: no name")
 107  		return
 108  	}
 109  	if p.Currency == "" {
 110  		err = errorf.E("product: no currency")
 111  		return
 112  	}
 113  	if p.Price == "" {
 114  		err = errorf.E("product: no price")
 115  		return
 116  	}
 117  	for i := range p.Specs {
 118  		if p.Specs[i].Key == "" {
 119  			err = errorf.E("product: spec %d has no key", i)
 120  			return
 121  		}
 122  	}
 123  	return
 124  }
 125  
 126  func (s *Stall) Validate() (err error) {
 127  	if s == nil {
 128  		err = errorf.E("nil stall")
 129  		return
 130  	}
 131  	if s.ID == "" {
 132  		err = errorf.E("stall: no id")
 133  		return
 134  	}
 135  	if s.Name == "" {
 136  		err = errorf.E("stall: no name")
 137  		return
 138  	}
 139  	if s.Currency == "" {
 140  		err = errorf.E("stall: no currency")
 141  		return
 142  	}
 143  	return
 144  }
 145  
 146  // JSON renders the stall content in the NIP-15 shape.
 147  func (s *Stall) JSON() (out string) {
 148  	out = "{\"id\":" | jsonString(s.ID) |
 149  		",\"name\":" | jsonString(s.Name) |
 150  		",\"currency\":" | jsonString(s.Currency) |
 151  		",\"shipping\":" | shippingJSON(s.Shipping) | "}"
 152  	return
 153  }
 154  
 155  // JSON renders the product content in the NIP-15 shape the examples show:
 156  // a flat object with the camel/snake keys, specs as an array of two-element
 157  // arrays, shipping as an array of objects.
 158  func (p *Product) JSON() (out string) {
 159  	out = "{\"id\":" | jsonString(p.ID) |
 160  		",\"stall_id\":" | jsonString(p.StallID) |
 161  		",\"name\":" | jsonString(p.Name) |
 162  		",\"description\":" | jsonString(p.Description) |
 163  		",\"images\":" | stringArrayJSON(p.Images) |
 164  		",\"currency\":" | jsonString(p.Currency) |
 165  		",\"price\":" | numberOrString(p.Price)
 166  	if p.Quantity != "" {
 167  		out = out | ",\"quantity\":" | numberOrString(p.Quantity)
 168  	} else {
 169  		out = out | ",\"quantity\":null"
 170  	}
 171  	out = out | ",\"specs\":" | specsJSON(p.Specs) |
 172  		",\"shipping\":" | shippingJSON(p.Shipping) | "}"
 173  	return
 174  }
 175  
 176  func specsJSON(specs []SpecPair) (out string) {
 177  	out = "["
 178  	for i := range specs {
 179  		if i > 0 {
 180  			out = out | ","
 181  		}
 182  		out = out | "[" | jsonString(specs[i].Key) | "," | jsonString(specs[i].Value) | "]"
 183  	}
 184  	return out | "]"
 185  }
 186  
 187  func shippingJSON(zones []ShippingZone) (out string) {
 188  	out = "["
 189  	for i := range zones {
 190  		if i > 0 {
 191  			out = out | ","
 192  		}
 193  		out = out | "{\"id\":" | jsonString(zones[i].ID) | ",\"cost\":" | numberOrString(zones[i].Cost) | "}"
 194  	}
 195  	return out | "]"
 196  }
 197  
 198  func stringArrayJSON(xs []string) (out string) {
 199  	out = "["
 200  	for i := range xs {
 201  		if i > 0 {
 202  			out = out | ","
 203  		}
 204  		out = out | jsonString(xs[i])
 205  	}
 206  	return out | "]"
 207  }
 208  
 209  // numberOrString writes a numeric value bare when it is one and quotes it
 210  // otherwise. The examples carry prices as JSON numbers and quantities as
 211  // numbers, but the wire only requires that a reader can parse it.
 212  func numberOrString(s string) (out string) {
 213  	if s == "" {
 214  		return "null"
 215  	}
 216  	if isDecimal(s) {
 217  		return s
 218  	}
 219  	return jsonString(s)
 220  }
 221  
 222  // jsonString quotes a value for JSON.
 223  func jsonString(s string) (out string) {
 224  	out = text.AppendQuote(nil, s, text.NostrEscape)
 225  	return
 226  }
 227  
 228  // ParseStall reads a NIP-15 stall content object. The raw content is kept: a
 229  // relay that stores the event does not need it re-rendered.
 230  func ParseStall(content string) (s *Stall, err error) {
 231  	if !isJSONObject(content) {
 232  		err = errorf.E("stall content is not a JSON object")
 233  		return
 234  	}
 235  	s = &Stall{Content: content}
 236  	s.ID = jsonFieldString(content, "id")
 237  	s.Name = jsonFieldString(content, "name")
 238  	s.Currency = jsonFieldString(content, "currency")
 239  	s.Shipping = parseShipping(jsonFieldRaw(content, "shipping"))
 240  	return
 241  }
 242  
 243  func ParseProduct(content string) (p *Product, err error) {
 244  	if !isJSONObject(content) {
 245  		err = errorf.E("product content is not a JSON object")
 246  		return
 247  	}
 248  	p = &Product{Content: content}
 249  	p.ID = jsonFieldString(content, "id")
 250  	p.StallID = jsonFieldString(content, "stall_id")
 251  	p.Name = jsonFieldString(content, "name")
 252  	p.Description = jsonFieldString(content, "description")
 253  	p.Images = parseStringArray(jsonFieldRaw(content, "images"))
 254  	p.Currency = jsonFieldString(content, "currency")
 255  	p.Price = jsonFieldRaw(content, "price")
 256  	p.Quantity = jsonFieldRaw(content, "quantity")
 257  	if p.Quantity == "null" {
 258  		p.Quantity = ""
 259  	}
 260  	p.Specs = parseSpecs(jsonFieldRaw(content, "specs"))
 261  	p.Shipping = parseShipping(jsonFieldRaw(content, "shipping"))
 262  	return
 263  }
 264  
 265  // Listing is the NIP-99 classified listing (kind 30402, draft 30403): a
 266  // service offered or wanted, or a one-off resale. A resale of physical music
 267  // carries the edition address for provenance.
 268  type Listing struct {
 269  	D           string
 270  	Title       string
 271  	Summary     string
 272  	PublishedAt string
 273  	Location    string
 274  	Geohash     string
 275  	Price       *Price
 276  	Hashtags    []string
 277  	Image       string
 278  	Status      string
 279  	Edition     *Address // resale provenance
 280  	Content     string
 281  	Extra       [][]string
 282  }
 283  
 284  func (l *Listing) Tags() (tags [][]string) {
 285  	tags = appendKV(nil, TagD, l.D)
 286  	tags = appendKV(tags, TagTitle, l.Title)
 287  	tags = appendKV(tags, TagSummary, l.Summary)
 288  	tags = appendKV(tags, TagPublishedAt, l.PublishedAt)
 289  	tags = appendKV(tags, TagLocation, l.Location)
 290  	tags = appendKV(tags, TagG, l.Geohash)
 291  	tags = appendTag(tags, l.Price.Tag())
 292  	for i := range l.Hashtags {
 293  		tags = appendKV(tags, TagT, l.Hashtags[i])
 294  	}
 295  	tags = appendKV(tags, TagImage, l.Image)
 296  	tags = appendKV(tags, TagStatus, l.Status)
 297  	tags = appendTag(tags, l.Edition.Tag())
 298  	tags = appendTags(tags, l.Extra)
 299  	return
 300  }
 301  
 302  func ParseListing(tags [][]string, content string) (l *Listing, err error) {
 303  	l = &Listing{Content: content}
 304  	for i := range tags {
 305  		tg := tags[i]
 306  		if len(tg) < 1 {
 307  			continue
 308  		}
 309  		switch tg[0] {
 310  		case TagD:
 311  			l.D = elem(tg, 1)
 312  		case TagTitle:
 313  			l.Title = elem(tg, 1)
 314  		case TagSummary:
 315  			l.Summary = elem(tg, 1)
 316  		case TagPublishedAt:
 317  			l.PublishedAt = elem(tg, 1)
 318  		case TagLocation:
 319  			l.Location = elem(tg, 1)
 320  		case TagG:
 321  			l.Geohash = elem(tg, 1)
 322  		case TagPrice:
 323  			l.Price = parsePrice(tg)
 324  		case TagT:
 325  			l.Hashtags = push(l.Hashtags, elem(tg, 1))
 326  		case TagImage:
 327  			l.Image = elem(tg, 1)
 328  		case TagStatus:
 329  			l.Status = elem(tg, 1)
 330  		case TagA:
 331  			a, aerr := AddressFromTag(tg)
 332  			if aerr != nil {
 333  				err = aerr
 334  				return
 335  			}
 336  			l.Edition = a
 337  		default:
 338  			l.Extra = appendTag(l.Extra, tg)
 339  		}
 340  	}
 341  	return
 342  }
 343  
 344  func (l *Listing) Validate() (err error) {
 345  	if l == nil {
 346  		err = errorf.E("nil listing")
 347  		return
 348  	}
 349  	if l.D == "" {
 350  		err = errorf.E("listing: empty d tag")
 351  		return
 352  	}
 353  	if l.Title == "" {
 354  		err = errorf.E("listing: no title")
 355  		return
 356  	}
 357  	if l.Edition != nil && l.Edition.Kind != KindEdition {
 358  		err = errorf.E("listing: provenance reference is kind %d, want %d", l.Edition.Kind, KindEdition)
 359  		return
 360  	}
 361  	switch l.Status {
 362  	case "", StatusActive:
 363  	default:
 364  		// The documents name only "active" and say a listing can be filled or
 365  		// closed; a closed value is not specified, so it is carried.
 366  	}
 367  	return
 368  }
 369  
 370  // --- a tiny JSON reader for the NIP-15 payloads ---
 371  //
 372  // Moxie has no `any`, so there is no general JSON value type to build on. The
 373  // NIP-15 payloads are a fixed shape, so the reader answers the questions that
 374  // shape needs: a top-level field's raw value, a string field, an array of
 375  // strings, an array of two-element arrays, an array of {id, cost} objects.
 376  
 377  func isJSONObject(s string) (ok bool) {
 378  	i := jsonSkipWS(s, 0)
 379  	return i >= 0 && i < len(s) && s[i] == '{'
 380  }
 381  
 382  // jsonSkipWS returns the index of the next non-whitespace byte, or -1.
 383  func jsonSkipWS(s string, i int32) (j int32) {
 384  	for ; i < len(s); i++ {
 385  		switch s[i] {
 386  		case ' ', '\t', '\n', '\r':
 387  			continue
 388  		}
 389  		return i
 390  	}
 391  	return -1
 392  }
 393  
 394  // jsonFieldRaw finds a top-level field's value and returns it verbatim (for a
 395  // scalar) or its text including braces (for an array or object). It returns ""
 396  // when the field is absent, and "null" for an explicit null.
 397  func jsonFieldRaw(obj, key string) (out string) {
 398  	i := jsonSkipWS(obj, 0)
 399  	if i < 0 || obj[i] != '{' {
 400  		return ""
 401  	}
 402  	i++
 403  	for i < len(obj) {
 404  		i = jsonSkipWS(obj, i)
 405  		if i < 0 || i >= len(obj) {
 406  			return ""
 407  		}
 408  		if obj[i] == '}' {
 409  			return ""
 410  		}
 411  		if obj[i] == ',' {
 412  			i++
 413  			continue
 414  		}
 415  		if obj[i] != '"' {
 416  			return ""
 417  		}
 418  		name, next, err := jsonReadString(obj, i)
 419  		if err != nil {
 420  			return ""
 421  		}
 422  		i = jsonSkipWS(obj, next)
 423  		if i < 0 || obj[i] != ':' {
 424  			return ""
 425  		}
 426  		i = jsonSkipWS(obj, i+1)
 427  		if i < 0 {
 428  			return ""
 429  		}
 430  		end, verr := jsonSkipValue(obj, i)
 431  		if verr != nil {
 432  			return ""
 433  		}
 434  		if name == key {
 435  			return obj[i:end]
 436  		}
 437  		i = end
 438  	}
 439  	return ""
 440  }
 441  
 442  func jsonFieldString(obj, key string) (out string) {
 443  	raw := jsonFieldRaw(obj, key)
 444  	if raw == "" || raw == "null" {
 445  		return ""
 446  	}
 447  	if raw[0] != '"' {
 448  		return ""
 449  	}
 450  	v, _, err := jsonReadString(raw, 0)
 451  	if err != nil {
 452  		return ""
 453  	}
 454  	return v
 455  }
 456  
 457  // jsonReadString reads a quoted string starting at i and returns the decoded
 458  // value plus the index after the closing quote.
 459  func jsonReadString(s string, i int32) (val string, next int32, err error) {
 460  	if i >= len(s) || s[i] != '"' {
 461  		err = errorf.E("json: expected a string")
 462  		return
 463  	}
 464  	content, rem, uerr := text.UnmarshalQuoted(s[i:])
 465  	if uerr != nil {
 466  		err = uerr
 467  		return
 468  	}
 469  	val = string(content)
 470  	next = i + (int32(len(s[i:])) - int32(len(rem)))
 471  	return
 472  }
 473  
 474  // jsonSkipValue returns the index just past the value starting at i, walking
 475  // nested arrays and objects.
 476  func jsonSkipValue(s string, i int32) (next int32, err error) {
 477  	if i >= len(s) {
 478  		err = errorf.E("json: value expected")
 479  		return
 480  	}
 481  	switch s[i] {
 482  	case '"':
 483  		_, n, serr := jsonReadString(s, i)
 484  		if serr != nil {
 485  			err = serr
 486  			return
 487  		}
 488  		return n, nil
 489  	case '[', '{':
 490  		open := s[i]
 491  		closeB := byte(']')
 492  		if open == '{' {
 493  			closeB = '}'
 494  		}
 495  		depth := int32(0)
 496  		for k := i; k < len(s); k++ {
 497  			if s[k] == '"' {
 498  				_, n, serr := jsonReadString(s, k)
 499  				if serr != nil {
 500  					err = serr
 501  					return
 502  				}
 503  				k = n - 1
 504  				continue
 505  			}
 506  			if s[k] == open {
 507  				depth++
 508  				continue
 509  			}
 510  			if s[k] == closeB {
 511  				depth--
 512  				if depth == 0 {
 513  					return k + 1, nil
 514  				}
 515  			}
 516  		}
 517  		err = errorf.E("json: unterminated container")
 518  		return
 519  	}
 520  	// A number, true, false or null: run to the next delimiter.
 521  	j := i
 522  	for ; j < len(s); j++ {
 523  		switch s[j] {
 524  		case ',', '}', ']':
 525  			return jsonTrimRight(s, i, j), nil
 526  		}
 527  	}
 528  	return j, nil
 529  }
 530  
 531  func jsonTrimRight(s string, from, to int32) (end int32) {
 532  	for to > from {
 533  		c := s[to-1]
 534  		if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
 535  			to--
 536  			continue
 537  		}
 538  		break
 539  	}
 540  	return to
 541  }
 542  
 543  // jsonElements splits a container's top-level elements, returning each
 544  // element's raw text.
 545  func jsonElements(s string) (out []string) {
 546  	i := jsonSkipWS(s, 0)
 547  	if i < 0 || (s[i] != '[' && s[i] != '{') {
 548  		return nil
 549  	}
 550  	closing := byte(']')
 551  	if s[i] == '{' {
 552  		closing = '}'
 553  	}
 554  	i++
 555  	for i < len(s) {
 556  		i = jsonSkipWS(s, i)
 557  		if i < 0 || i >= len(s) {
 558  			return
 559  		}
 560  		if s[i] == closing {
 561  			return
 562  		}
 563  		if s[i] == ',' {
 564  			i++
 565  			continue
 566  		}
 567  		end, err := jsonSkipValue(s, i)
 568  		if err != nil {
 569  			return
 570  		}
 571  		out = push(out, s[i:end])
 572  		i = end
 573  	}
 574  	return
 575  }
 576  
 577  func parseStringArray(raw string) (out []string) {
 578  	if raw == "" || raw == "null" {
 579  		return nil
 580  	}
 581  	els := jsonElements(raw)
 582  	out = []string{:0:int32(len(els))}
 583  	for i := range els {
 584  		if len(els[i]) > 0 && els[i][0] == '"' {
 585  			v, _, err := jsonReadString(els[i], 0)
 586  			if err == nil {
 587  				out = push(out, v)
 588  			}
 589  		}
 590  	}
 591  	return
 592  }
 593  
 594  func parseSpecs(raw string) (out []SpecPair) {
 595  	if raw == "" || raw == "null" {
 596  		return nil
 597  	}
 598  	els := jsonElements(raw)
 599  	for i := range els {
 600  		inner := jsonElements(els[i])
 601  		if len(inner) < 2 {
 602  			continue
 603  		}
 604  		k, _, err := jsonReadString(inner[0], 0)
 605  		if err != nil {
 606  			continue
 607  		}
 608  		v, _, verr := jsonReadString(inner[1], 0)
 609  		if verr != nil {
 610  			continue
 611  		}
 612  		out = push(out, SpecPair{Key: k, Value: v})
 613  	}
 614  	return
 615  }
 616  
 617  func parseShipping(raw string) (out []ShippingZone) {
 618  	if raw == "" || raw == "null" {
 619  		return nil
 620  	}
 621  	els := jsonElements(raw)
 622  	for i := range els {
 623  		id := jsonFieldString(els[i], "id")
 624  		cost := jsonFieldRaw(els[i], "cost")
 625  		if id == "" {
 626  			continue
 627  		}
 628  		out = push(out, ShippingZone{ID: id, Cost: cost})
 629  	}
 630  	return
 631  }
 632