Repository: brianvoe/gofakeit

Issue:
Support ISBN 10 and ISBN13
Is it possible to support something similar to https://github.com/faker-js/faker/issues/2196?
Ref: https://fakerjs.dev/api/commerce#isbn

I am learning Go, and would be happy to contribute if we agree.
Let me know if this needs to be part of a Book instance or if we need to create a separate `Commerce` struct to manage this resource?

Discussion:
@brianvoe: Yes go for it! I would have no problem adding ISBN. Plus it will be added to gofakeit.com

Since your new Ill give you a little checklist for pr's

1. Function - Add it in the right file
2. 3 functions - Global, Struct and private
3. Lookup - each file at the end has a lookup so it can be used in other places
4. Tests - each file also has a corresponding tests file. make sure to add your stuff there too

Thanks again and good luck!

Solution patch:
diff --git a/data/isbn.go b/data/isbn.go
new file mode 100644
index 0000000..33f1dbf
--- /dev/null
+++ b/data/isbn.go
@@ -0,0 +1,45 @@
+package data
+
+// Ref:
+// 	- https://www.isbn-international.org/range_file_generation
+// 	- https://github.com/joke2k/faker/blob/master/faker/providers/isbn/en_US/__init__.py
+
+const PrefixGroup1 = "978"
+
+type RegistrantElements struct {
+	Min    string
+	Max    string
+	Length int
+}
+type Rule map[string][]RegistrantElements
+
+var ISBNRules = map[string]Rule{
+	PrefixGroup1: {
+		// Registration Group
+		"0": {
+			// Registrant Rule (min, max, registrant element length)
+			{"0000000", "1999999", 2},
+			{"2000000", "2279999", 3},
+			{"2280000", "2289999", 4},
+			{"2290000", "6479999", 3},
+			{"6480000", "6489999", 7},
+			{"6490000", "6999999", 3},
+			{"7000000", "8499999", 4},
+			{"8500000", "8999999", 5},
+			{"9000000", "9499999", 6},
+			{"9500000", "9999999", 7},
+		},
+		"1": {
+			{"0000000", "0999999", 2},
+			{"1000000", "3999999", 3},
+			{"4000000", "5499999", 4},
+			{"5500000", "7319999", 5},
+			{"7320000", "7399999", 7},
+			{"7400000", "8697999", 5},
+			{"8698000", "9729999", 6},
+			{"9730000", "9877999", 4},
+			{"9878000", "9989999", 6},
+			{"9990000", "9999999", 7},
+		},
+	},
+}
diff --git a/product.go b/product.go
index 23f8c8b..e606da0 100644
--- a/product.go
+++ b/product.go
@@ -2,9 +2,30 @@ package gofakeit
 
 import (
 	"fmt"
+	"log"
+	"strconv"
 	"strings"
+
+	"github.com/brianvoe/gofakeit/v7/data"
 )
 
+type createChecksumFn func(digits string) string
+
+// This pattern is inspired from: https://pkg.go.dev/database/sql#NullInt64
+type NullString struct {
+	String string
+	Valid  bool // Valid is true if String is not NULL
+}
+
+type ISBN struct {
+	Ean         NullString `json:"ean"`
+	Group       string     `json:"group"`
+	Registrant  string     `json:"registrant"`
+	Publication string     `json:"publication"`
+	Check       string     `json:"checksum"`
+	separator   string
+}
+
 type ProductInfo struct {
 	Name        string   `json:"name" xml:"name"`
 	Description string   `json:"description" xml:"description"`
@@ -19,6 +40,24 @@ type ProductInfo struct {
 	UseCase     string   `json:"use_case" xml:"use_case"`
 	Benefit     string   `json:"benefit" xml:"benefit"`
 	Suffix      string   `json:"suffix" xml:"suffix"`
+	ISBN        string   `json:"isbn" xml:"isbn"`
+	ISBN10      string   `json:"isbn10" xml:"isbn10"`
+}
+
+func (ele *ISBN) String() string {
+	elements := []string{ele.Ean.String, ele.Group, ele.Registrant, ele.Publication, ele.Check}
+	if !ele.Ean.Valid {
+		elements = elements[1:]
+	}
+	return strings.Join(elements, ele.separator)
+}
+
+func keys[T any](v map[string]T) *[]string {
+	var keys []string
+	for k := range v {
+		keys = append(keys, k)
+	}
+	return &keys
 }
 
 // Product will generate a random set of product information
@@ -56,6 +95,8 @@ func product(f *Faker) *ProductInfo {
 		UseCase:     productUseCase(f),
 		Benefit:     productBenefit(f),
 		Suffix:      productSuffix(f),
+		ISBN:        productISBN(f, "-"),
+		ISBN10:      productISBN10(f, "-"),
 	}
 
 	return product
@@ -233,6 +274,133 @@ func productSuffix(f *Faker) string {
 	return getRandValue(f, []string{"product", "suffix"})
 }
 
+// Registrant and Publication are two separate elements in ISBN string, and can have variable lengths
+// depending on rules defined for each ISBN agencies. Currently our logic is focused into breaking
+// the combined Reg/Pub string for US Registration Groups only (i.e. 0 & 1).
+// This may change in future depending on how many regions we want to support and providing rules
+// for each of them.
+func getRegistrantPublication(regPub string, rules []data.RegistrantElements) (string, string, error) {
+	regLength := len(regPub)
+	ruleFound := false
+	for _, rule := range rules {
+		iRule0, _ := strconv.Atoi(rule.Min)
+		iRule1, _ := strconv.Atoi(rule.Max)
+		iRegPub, _ := strconv.Atoi(regPub[:regLength-1])
+		if iRule0 <= iRegPub && iRegPub <= iRule1 {
+			regLength = rule.Length
+			ruleFound = true
+			break
+		}
+	}
+
+	if !ruleFound {
+		return "", "", fmt.Errorf("registrant/publication %s not found in any Registrant Rules", regPub)
+	}
+
+	return regPub[:regLength], regPub[regLength:], nil
+}
+
+// Since ISBN is divided into 4/5 string segments of variable lengths, we first need to
+// prepare each segment separately and forward it to the consumer.
+// Details on each ISBN element can be read here: https://www.isbn-international.org/content/what-isbn/10
+func prepareElements(f *Faker) (*ISBN, error) {
+	rules := data.ISBNRules
+	ean := f.RandomString(*keys(rules))
+	regGroup := f.RandomString(*keys(rules[ean]))
+
+	// Based on the lengths of EAN/Registration group, we need to evaluate the length of registrant & publication
+	// length which would be length of EAN, Registration Group, Check Digit (checksum), subtracted from
+	// ISBN13 string length
+	regPubLength := 13 - len(ean) - len(regGroup) - 1
+	regPub := f.Numerify(strings.Repeat("#", regPubLength))
+
+	regPubRules := rules[ean][regGroup]
+	if registrant, publication, err := getRegistrantPublication(regPub, regPubRules); err != nil {
+		return nil, err
+	} else {
+		return &ISBN{
+			Ean:         NullString{ean, true},
+			Group:       regGroup,
+			Registrant:  registrant,
+			Publication: publication,
+		}, nil
+	}
+}
+
+func createISBN(elements *ISBN, fn createChecksumFn, sep string) string {
+	digits := elements.String()
+	// fmt.Println("Digits:", digits)
+	elements.Check = fn(digits)
+	elements.separator = sep
+	return fmt.Sprint(elements)
+}
+
+// Find the checksum/check digit to complete the ISBN string
+// Ref: https://en.wikipedia.org/wiki/ISBN#ISBN-10_check_digit_calculation
+func createISBN10Checksum(digits string) string {
+	sum := 0
+	for i, c := range digits {
+		digit := int(c - '0')
+		digit *= 10 - i
+		sum += digit
+	}
+
+	var result string
+	remainder := (11 - (sum % 11)) % 11
+	if remainder == 10 {
+		result = "X"
+	} else {
+		result = strconv.Itoa(remainder)
+	}
+	return result
+}
+
+// Find the checksum/check digit to complete the ISBN string
+// Ref: https://en.wikipedia.org/wiki/ISBN#ISBN-13_check_digit_calculation
+func createISBN13Checksum(digits string) string {
+	sum := 0
+	for i, c := range digits {
+		digit := int(c - '0') // Converts rune to it's ASCII integer value
+		if i%2 != 0 {
+			digit *= 3
+		}
+		sum += digit
+	}
+
+	remainder := (10 - (sum % 10)) % 10
+	return strconv.Itoa(remainder)
+}
+
+// ProductISBN10 will generate a random ISBN-10 string for the product
+func ProductISBN10(sep string) string { return productISBN10(GlobalFaker, sep) }
+
+// ProductISBN10 will generate a random ISBN-10 string for the product
+func (f *Faker) ProductISBN10(sep string) string { return productISBN10(f, sep) }
+
+func productISBN10(f *Faker, sep string) string {
+	elements, err := prepareElements(f)
+	if err != nil {
+		log.Fatalf("unable to generate ISBN10 string: %v", err)
+		return ""
+	}
+	elements.Ean.Valid = false
+	return createISBN(elements, createISBN10Checksum, sep)
+}
+
+// ProductISBN will generate a random ISBN-13 string for the product
+func ProductISBN(sep string) string { return productISBN(GlobalFaker, sep) }
+
+// ProductISBN will generate a random ISBN-13 string for the product
+func (f *Faker) ProductISBN(sep string) string { return productISBN(f, sep) }
+
+func productISBN(f *Faker, sep string) string {
+	elements, err := prepareElements(f)
+	if err != nil {
+		log.Fatalf("unable to generate ISBN13 string: %v", err)
+	}
+	return createISBN(elements, createISBN13Checksum, sep)
+}
+
 func addProductLookup() {
 	AddFuncLookup("product", Info{
 		Display:     "Product",
@@ -259,6 +427,8 @@ func addProductLookup() {
 	"use_case": "home",
 	"benefit": "comfort",
 	"suffix": "pro"
+	"isbn": "978-1-4028-9462-6"
+	"isbn10": "1-4028-9462-7"
 }`,
 		Output:      "map[string]any",
 		ContentType: "application/json",
@@ -387,4 +557,26 @@ func addProductLookup() {
 			return productSuffix(f), nil
 		},
 	})
+
+	AddFuncLookup("productisbn", Info{
+		Display:     "Product ISBN",
+		Category:    "product",
+		Description: "ISBN string for the product",
+		Example:     "978-1-4028-9462-6",
+		Output:      "string",
+		Generate: func(f *Faker, m *MapParams, info *Info) (any, error) {
+			return productISBN(f, "-"), nil
+		},
+	})
+
+	AddFuncLookup("productisbn10", Info{
+		Display:     "Product ISBN-10",
+		Category:    "product",
+		Description: "ISBN-10 string for the product used before Jan 01, 2007",
+		Example:     "1-4028-9462-7",
+		Output:      "string",
+		Generate: func(f *Faker, m *MapParams, info *Info) (any, error) {
+			return productISBN10(f, "-"), nil
+		},
+	})
 }



Test patch:
diff --git a/product_test.go b/product_test.go
index 4766e37..29c6b54 100644
--- a/product_test.go
+++ b/product_test.go
@@ -22,6 +22,8 @@ func ExampleProduct() {
 	fmt.Println(product.UseCase)
 	fmt.Println(product.Benefit)
 	fmt.Println(product.Suffix)
+	fmt.Println(product.ISBN)
+	fmt.Println(product.ISBN10)
 
 	// Output: Wave Precision Lamp
 	// This upset product is crafted from wood and includes wireless, making it perfect for personal grooming and delivering comfort for travelers and professionals.
@@ -36,6 +38,8 @@ func ExampleProduct() {
 	// learning
 	// efficiency
 	// dash
+	// 978-0-570-93875-0
+	// 0-201-74782-0
 }
 
 func ExampleFaker_Product() {
@@ -54,6 +58,8 @@ func ExampleFaker_Product() {
 	fmt.Println(product.UseCase)
 	fmt.Println(product.Benefit)
 	fmt.Println(product.Suffix)
+	fmt.Println(product.ISBN)
+	fmt.Println(product.ISBN10)
 
 	// Output: Wave Precision Lamp
 	// This upset product is crafted from wood and includes wireless, making it perfect for personal grooming and delivering comfort for travelers and professionals.
@@ -68,6 +74,8 @@ func ExampleFaker_Product() {
 	// learning
 	// efficiency
 	// dash
+	// 978-0-570-93875-0
+	// 0-201-74782-0
 }
 
 func TestProduct(t *testing.T) {
@@ -124,6 +132,20 @@ func TestProduct(t *testing.T) {
 		if product.Suffix == "" {
 			t.Error("Suffix is empty")
 		}
+
+		if product.ISBN == "" {
+			t.Error("Suffix is empty")
+		}
+		if product.ISBN10 == "" {
+			t.Error("Suffix is empty")
+		}
+
+		if len(strings.ReplaceAll(product.ISBN, "-", "")) != 13 {
+			t.Errorf("\"%s\" is not a valid ISBN string with length 13", product.ISBN)
+		}
+		if len(strings.ReplaceAll(product.ISBN10, "-", "")) != 10 {
+			t.Errorf("\"%s\" is not a valid ISBN string with length 10", product.ISBN10)
+		}
 	}
 }
 
@@ -362,3 +384,43 @@ func BenchmarkProductSuffix(b *testing.B) {
 		ProductSuffix()
 	}
 }
+
+func ExampleProductISBN() {
+	Seed(11)
+	fmt.Println(ProductISBN("-"))
+
+	// Output: 978-0-8125-2759-9
+}
+
+func ExampleFaker_ProductISBN() {
+	f := New(11)
+	fmt.Println(f.ProductISBN("-"))
+
+	// Output: 978-0-8125-2759-9
+}
+
+func BenchmarkProductISBN(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		ProductISBN("-")
+	}
+}
+
+func ExampleProductISBN10() {
+	Seed(11)
+	fmt.Println(ProductISBN10("-"))
+
+	// Output: 0-8125-2759-3
+}
+
+func ExampleFaker_ProductISBN10() {
+	f := New(11)
+	fmt.Println(f.ProductISBN10("-"))
+
+	// Output: 0-8125-2759-3
+}
+
+func BenchmarkProductISBN10(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		ProductISBN("-")
+	}
+}