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