├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── shopify-csv-download │ └── main.go ├── go.mod ├── go.sum ├── install.sh ├── internal ├── dependency_injection │ └── dependency_injection.go ├── models │ ├── progress │ │ └── state.go │ └── shopify │ │ └── models.go ├── resources │ ├── shopify_resource.go │ └── shopify_resource_test.go └── services │ ├── product_csv_conversion.go │ ├── product_csv_conversion_test.go │ ├── product_retrieval.go │ └── products_csv_writer.go └── pkg ├── products ├── writer.go └── writer_integration_test.go └── progress └── progress_state.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: 1.17 18 | - name: Install dependencies 19 | run: | 20 | go mod download 21 | curl -sfL https://install.goreleaser.com/github.com/goreleaser/goreleaser.sh | sh 22 | - name: Test 23 | run: | 24 | make build 25 | ./bin/goreleaser check -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: 1.17 23 | - name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@v2 25 | with: 26 | distribution: goreleaser 27 | version: latest 28 | args: release --rm-dist 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GA_GORELEASER_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | .idea/* 15 | .vscode/* 16 | *.csv* 17 | bin/* 18 | shopify-csv-download 19 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: shopify-csv-download 2 | 3 | before: 4 | hooks: 5 | - go mod download 6 | 7 | release: 8 | github: 9 | owner: kishaningithub 10 | name: shopify-csv-download 11 | 12 | builds: 13 | - main: ./cmd/shopify-csv-download/main.go 14 | binary: shopify-csv-download 15 | goos: 16 | - windows 17 | - darwin 18 | - linux 19 | goarch: 20 | - amd64 21 | - arm64 22 | 23 | brews: 24 | - tap: 25 | owner: kishaningithub 26 | name: homebrew-tap 27 | folder: Formula 28 | homepage: https://github.com/kishaningithub/shopify-csv-download 29 | description: Download a shopify site in a csv format that the shopify importer understands 30 | license: MIT 31 | 32 | nfpms: 33 | - id: shopify-csv-download 34 | package_name: shopify-csv-download 35 | homepage: https://github.com/kishaningithub/shopify-csv-download 36 | description: Download a shopify site in a csv format that the shopify importer understands 37 | license: MIT 38 | formats: 39 | - apk 40 | - deb 41 | - rpm -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kishan B 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | unit-test: 2 | go test -v ./... 3 | 4 | build: download-deps tidy-deps fmt unit-test compile 5 | 6 | fmt: ## Run the code formatter 7 | gofmt -l -s -w . 8 | 9 | download-deps: 10 | go mod download 11 | 12 | tidy-deps: 13 | go mod tidy 14 | 15 | update-deps: 16 | go get -u ./... 17 | go mod tidy 18 | 19 | compile: 20 | go build -v ./... -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shopify csv download 2 | 3 | [![Build Status](https://github.com/kishaningithub/shopify-csv-download/actions/workflows/build.yml/badge.svg)](https://github.com/kishaningithub/shopify-csv-download/actions/workflows/build.yml) 4 | [![Go Doc](https://godoc.org/github.com/kishaningithub/shopify-csv-download?status.svg)](https://godoc.org/github.com/kishaningithub/shopify-csv-download) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/kishaningithub/shopify-csv-download)](https://goreportcard.com/report/github.com/kishaningithub/shopify-csv-download) 6 | [![standard-readme compliant](https://img.shields.io/badge/standard--readme-OK-green.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) 7 | [![Latest release](https://img.shields.io/github/release/kishaningithub/shopify-csv-download.svg)](https://github.com/kishaningithub/shopify-csv-download/releases) 8 | [![Buy me a lunch](https://img.shields.io/badge/🍱-Buy%20me%20a%20lunch-blue.svg)](https://www.paypal.me/kishansh/15) 9 | 10 | Download a shopify site in a csv format that the [shopify importer understands](https://help.shopify.com/en/manual/products/import-export/using-csv#product-csv-file-format) 11 | 12 | ## Table of Contents 13 | 14 | - [shopify csv download](#shopify-csv-download) 15 | - [Table of Contents](#table-of-contents) 16 | - [Install](#install) 17 | - [Using Homebrew](#using-homebrew) 18 | - [Using Binary](#using-binary) 19 | - [Usage](#usage) 20 | - [CLI](#CLI) 21 | - [Library](#Library) 22 | - [Maintainers](#maintainers) 23 | - [Contribute](#contribute) 24 | - [License](#license) 25 | 26 | ## Install 27 | 28 | ### Using Homebrew 29 | 30 | ```bash 31 | brew install kishaningithub/tap/shopify-csv-download 32 | ``` 33 | 34 | ### Using Binary 35 | 36 | ```bash 37 | # All unix environments with curl 38 | curl -sfL https://raw.githubusercontent.com/kishaningithub/shopify-csv-download/master/install.sh | sudo sh -s -- -b /usr/local/bin 39 | 40 | # In alpine linux (as it does not come with curl by default) 41 | wget -O - -q https://raw.githubusercontent.com/kishaningithub/shopify-csv-download/master/install.sh | sudo sh -s -- -b /usr/local/bin 42 | ``` 43 | 44 | ## Usage 45 | 46 | ### CLI 47 | 48 | Retrieving all publicly exposed products 49 | 50 | ```bash 51 | shopify-csv-download https://shopify-site.com > shopify-site-products.csv 52 | ``` 53 | 54 | ### Library 55 | 56 | ```go 57 | package main 58 | 59 | import ( 60 | "log" 61 | "net/url" 62 | "os" 63 | 64 | "github.com/kishaningithub/shopify-csv-download/pkg/products" 65 | ) 66 | 67 | func main() { 68 | siteUrl, err := url.Parse("https://shopify-site.com") 69 | if err != nil { 70 | log.Println(err) 71 | return 72 | } 73 | err = products.SaveAsImportableCSV(*siteUrl, os.Stdout) 74 | if err != nil { 75 | log.Println(err) 76 | return 77 | } 78 | } 79 | ``` 80 | 81 | ## Maintainers 82 | 83 | [@kishaningithub](https://github.com/kishaningithub) 84 | 85 | ## Contribute 86 | 87 | PRs accepted. 88 | 89 | Small note: If editing the README, please conform to the [standard-readme](https://github.com/RichardLitt/standard-readme) specification. 90 | 91 | ## License 92 | 93 | MIT © 2021 Kishan B 94 | -------------------------------------------------------------------------------- /cmd/shopify-csv-download/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/bradhe/stopwatch" 6 | "github.com/jessevdk/go-flags" 7 | "github.com/kishaningithub/shopify-csv-download/pkg/products" 8 | "github.com/kishaningithub/shopify-csv-download/pkg/progress" 9 | "net/url" 10 | "os" 11 | "strings" 12 | ) 13 | 14 | var opts struct { 15 | } 16 | 17 | func main() { 18 | productsJsonURL, err := url.Parse(parseArgsAndGetStoreUrl()) 19 | exitOnFailure(fmt.Sprintf("unable to parse url %s", productsJsonURL), err) 20 | logWithNewLine("Downloading products as CSV...") 21 | watch := stopwatch.Start() 22 | err = products.SaveAsImportableCSVNotifyingProgressState(*productsJsonURL, os.Stdout, progressHandler) 23 | exitOnFailure("unable to write products", err) 24 | logWithNewLine("") 25 | watch.Stop() 26 | logWithNewLine("Save complete. Time taken %s", watch.String()) 27 | } 28 | 29 | func progressHandler(state progress.State) { 30 | progressStateLineFormat := "%d products downloaded... %d products written in CSV..." 31 | logInTheSameLine(progressStateLineFormat, state.NoOfProductsDownloaded, state.NoOfProductsConvertedAsCSV) 32 | } 33 | 34 | func logWithNewLine(format string, args ...interface{}) { 35 | _, _ = fmt.Fprintf(os.Stderr, format, args...) 36 | _, _ = fmt.Fprintln(os.Stderr) 37 | } 38 | 39 | func logInTheSameLine(format string, args ...interface{}) { 40 | _, _ = fmt.Fprintf(os.Stderr, "\r"+format, args...) 41 | } 42 | 43 | func parseArgsAndGetStoreUrl() string { 44 | remainingArgs, err := flags.Parse(&opts) 45 | exitOnFailure("unable to parse flags", err) 46 | baseURL := strings.Join(remainingArgs, "") 47 | return baseURL 48 | } 49 | 50 | func exitOnFailure(message string, err error) { 51 | if err != nil { 52 | logWithNewLine(fmt.Errorf("%s: %w", message, err).Error()) 53 | os.Exit(1) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kishaningithub/shopify-csv-download 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/bradhe/stopwatch v0.0.0-20190618212248-a58cccc508ea 7 | github.com/gocarina/gocsv v0.0.0-20211020200912-82fc2684cc48 8 | github.com/jarcoal/httpmock v1.0.8 9 | github.com/jessevdk/go-flags v1.5.0 10 | github.com/stretchr/testify v1.7.0 11 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c 12 | golang.org/x/sys v0.1.0 // indirect 13 | ) 14 | 15 | require ( 16 | github.com/davecgh/go-spew v1.1.0 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bradhe/stopwatch v0.0.0-20190618212248-a58cccc508ea h1:+GIgqdjrcKMHK1JqC1Bb9arFtNOGX/SWCkueobreyQU= 2 | github.com/bradhe/stopwatch v0.0.0-20190618212248-a58cccc508ea/go.mod h1:P/j2DSP/kCOakHBACzMqmOdrTEieqdSiB3U9fqk7qgc= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/gocarina/gocsv v0.0.0-20211020200912-82fc2684cc48 h1:hLeicZW4XBuaISuJPfjkprg0SP0xxsQmb31aJZ6lnIw= 6 | github.com/gocarina/gocsv v0.0.0-20211020200912-82fc2684cc48/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= 7 | github.com/jarcoal/httpmock v1.0.8 h1:8kI16SoO6LQKgPE7PvQuV+YuD/inwHd7fOOe2zMbo4k= 8 | github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= 9 | github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= 10 | github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 14 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 15 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 16 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 17 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 18 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 19 | golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= 20 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 24 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Code generated by godownloader on 2020-03-07T17:36:56Z. DO NOT EDIT. 4 | # 5 | 6 | usage() { 7 | this=$1 8 | cat </dev/null 112 | } 113 | echoerr() { 114 | echo "$@" 1>&2 115 | } 116 | log_prefix() { 117 | echo "$0" 118 | } 119 | _logp=6 120 | log_set_priority() { 121 | _logp="$1" 122 | } 123 | log_priority() { 124 | if test -z "$1"; then 125 | echo "$_logp" 126 | return 127 | fi 128 | [ "$1" -le "$_logp" ] 129 | } 130 | log_tag() { 131 | case $1 in 132 | 0) echo "emerg" ;; 133 | 1) echo "alert" ;; 134 | 2) echo "crit" ;; 135 | 3) echo "err" ;; 136 | 4) echo "warning" ;; 137 | 5) echo "notice" ;; 138 | 6) echo "info" ;; 139 | 7) echo "debug" ;; 140 | *) echo "$1" ;; 141 | esac 142 | } 143 | log_debug() { 144 | log_priority 7 || return 0 145 | echoerr "$(log_prefix)" "$(log_tag 7)" "$@" 146 | } 147 | log_info() { 148 | log_priority 6 || return 0 149 | echoerr "$(log_prefix)" "$(log_tag 6)" "$@" 150 | } 151 | log_err() { 152 | log_priority 3 || return 0 153 | echoerr "$(log_prefix)" "$(log_tag 3)" "$@" 154 | } 155 | log_crit() { 156 | log_priority 2 || return 0 157 | echoerr "$(log_prefix)" "$(log_tag 2)" "$@" 158 | } 159 | uname_os() { 160 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 161 | case "$os" in 162 | cygwin_nt*) os="windows" ;; 163 | mingw*) os="windows" ;; 164 | msys_nt*) os="windows" ;; 165 | esac 166 | echo "$os" 167 | } 168 | uname_arch() { 169 | arch=$(uname -m) 170 | case $arch in 171 | x86_64) arch="amd64" ;; 172 | x86) arch="386" ;; 173 | i686) arch="386" ;; 174 | i386) arch="386" ;; 175 | aarch64) arch="arm64" ;; 176 | armv5*) arch="armv5" ;; 177 | armv6*) arch="armv6" ;; 178 | armv7*) arch="armv7" ;; 179 | esac 180 | echo ${arch} 181 | } 182 | uname_os_check() { 183 | os=$(uname_os) 184 | case "$os" in 185 | darwin) return 0 ;; 186 | dragonfly) return 0 ;; 187 | freebsd) return 0 ;; 188 | linux) return 0 ;; 189 | android) return 0 ;; 190 | nacl) return 0 ;; 191 | netbsd) return 0 ;; 192 | openbsd) return 0 ;; 193 | plan9) return 0 ;; 194 | solaris) return 0 ;; 195 | windows) return 0 ;; 196 | esac 197 | log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 198 | return 1 199 | } 200 | uname_arch_check() { 201 | arch=$(uname_arch) 202 | case "$arch" in 203 | 386) return 0 ;; 204 | amd64) return 0 ;; 205 | arm64) return 0 ;; 206 | armv5) return 0 ;; 207 | armv6) return 0 ;; 208 | armv7) return 0 ;; 209 | ppc64) return 0 ;; 210 | ppc64le) return 0 ;; 211 | mips) return 0 ;; 212 | mipsle) return 0 ;; 213 | mips64) return 0 ;; 214 | mips64le) return 0 ;; 215 | s390x) return 0 ;; 216 | amd64p32) return 0 ;; 217 | esac 218 | log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 219 | return 1 220 | } 221 | untar() { 222 | tarball=$1 223 | case "${tarball}" in 224 | *.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;; 225 | *.tar) tar --no-same-owner -xf "${tarball}" ;; 226 | *.zip) unzip "${tarball}" ;; 227 | *) 228 | log_err "untar unknown archive format for ${tarball}" 229 | return 1 230 | ;; 231 | esac 232 | } 233 | http_download_curl() { 234 | local_file=$1 235 | source_url=$2 236 | header=$3 237 | if [ -z "$header" ]; then 238 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 239 | else 240 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 241 | fi 242 | if [ "$code" != "200" ]; then 243 | log_debug "http_download_curl received HTTP status $code" 244 | return 1 245 | fi 246 | return 0 247 | } 248 | http_download_wget() { 249 | local_file=$1 250 | source_url=$2 251 | header=$3 252 | if [ -z "$header" ]; then 253 | wget -q -O "$local_file" "$source_url" 254 | else 255 | wget -q --header "$header" -O "$local_file" "$source_url" 256 | fi 257 | } 258 | http_download() { 259 | log_debug "http_download $2" 260 | if is_command curl; then 261 | http_download_curl "$@" 262 | return 263 | elif is_command wget; then 264 | http_download_wget "$@" 265 | return 266 | fi 267 | log_crit "http_download unable to find wget or curl" 268 | return 1 269 | } 270 | http_copy() { 271 | tmp=$(mktemp) 272 | http_download "${tmp}" "$1" "$2" || return 1 273 | body=$(cat "$tmp") 274 | rm -f "${tmp}" 275 | echo "$body" 276 | } 277 | github_release() { 278 | owner_repo=$1 279 | version=$2 280 | test -z "$version" && version="latest" 281 | giturl="https://github.com/${owner_repo}/releases/${version}" 282 | json=$(http_copy "$giturl" "Accept:application/json") 283 | test -z "$json" && return 1 284 | version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') 285 | test -z "$version" && return 1 286 | echo "$version" 287 | } 288 | hash_sha256() { 289 | TARGET=${1:-/dev/stdin} 290 | if is_command gsha256sum; then 291 | hash=$(gsha256sum "$TARGET") || return 1 292 | echo "$hash" | cut -d ' ' -f 1 293 | elif is_command sha256sum; then 294 | hash=$(sha256sum "$TARGET") || return 1 295 | echo "$hash" | cut -d ' ' -f 1 296 | elif is_command shasum; then 297 | hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 298 | echo "$hash" | cut -d ' ' -f 1 299 | elif is_command openssl; then 300 | hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 301 | echo "$hash" | cut -d ' ' -f a 302 | else 303 | log_crit "hash_sha256 unable to find command to compute sha-256 hash" 304 | return 1 305 | fi 306 | } 307 | hash_sha256_verify() { 308 | TARGET=$1 309 | checksums=$2 310 | if [ -z "$checksums" ]; then 311 | log_err "hash_sha256_verify checksum file not specified in arg2" 312 | return 1 313 | fi 314 | BASENAME=${TARGET##*/} 315 | want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) 316 | if [ -z "$want" ]; then 317 | log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" 318 | return 1 319 | fi 320 | got=$(hash_sha256 "$TARGET") 321 | if [ "$want" != "$got" ]; then 322 | log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" 323 | return 1 324 | fi 325 | } 326 | cat /dev/null <= http.StatusBadRequest && httpStatusCode < http.StatusInternalServerError 77 | } 78 | 79 | func (resource *shopifyResource) is5XXResponseCode(httpStatusCode int) bool { 80 | return httpStatusCode >= http.StatusInternalServerError && httpStatusCode < 600 81 | } 82 | -------------------------------------------------------------------------------- /internal/resources/shopify_resource_test.go: -------------------------------------------------------------------------------- 1 | package resources_test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jarcoal/httpmock" 6 | "github.com/kishaningithub/shopify-csv-download/internal/models/shopify" 7 | "github.com/kishaningithub/shopify-csv-download/internal/resources" 8 | "github.com/stretchr/testify/suite" 9 | "net/url" 10 | "testing" 11 | ) 12 | 13 | var _ suite.SetupTestSuite = &ResourceTestSuite{} 14 | var _ suite.TearDownTestSuite = &ResourceTestSuite{} 15 | 16 | type ResourceTestSuite struct { 17 | suite.Suite 18 | productsJsonUrl url.URL 19 | resource resources.ShopifyResource 20 | } 21 | 22 | func TestResourceTestSuite(t *testing.T) { 23 | suite.Run(t, new(ResourceTestSuite)) 24 | } 25 | 26 | func (suite *ResourceTestSuite) SetupTest() { 27 | productsJsonUrl, _ := url.Parse("https://example.com") 28 | suite.productsJsonUrl = *productsJsonUrl 29 | suite.resource = resources.NewShopifyResource(suite.productsJsonUrl) 30 | httpmock.Activate() 31 | } 32 | 33 | func (suite *ResourceTestSuite) TearDownTest() { 34 | httpmock.DeactivateAndReset() 35 | } 36 | 37 | func (suite *ResourceTestSuite) TestGetProducts_ShouldFetchProductsAsPerGivenCriteria() { 38 | httpmock.RegisterResponder("GET", "https://example.com/products.json?limit=1&page=1", 39 | httpmock.NewStringResponder(200, ` 40 | { 41 | "products": [ 42 | { 43 | "handle": "awesome-product" 44 | } 45 | ] 46 | } 47 | `)) 48 | expectedProductsResponse := shopify.ProductsResponse{ 49 | Products: []shopify.Product{ 50 | { 51 | Handle: "awesome-product", 52 | }, 53 | }, 54 | } 55 | 56 | productsResponse, err := suite.resource.GetProducts(1, 1) 57 | 58 | suite.Require().NoError(err) 59 | suite.Require().Equal(expectedProductsResponse, productsResponse) 60 | } 61 | 62 | func (suite *ResourceTestSuite) TestGetProducts_ShouldReturnFailureWithAppropriateLogWhenUrlIsNotFound() { 63 | httpmock.RegisterResponder("GET", "https://example.com/products.json?limit=1&page=1", 64 | httpmock.NewStringResponder(404, "")) 65 | expectedErr := fmt.Errorf("the url https://example.com/products.json?limit=1&page=1 is not found, this could be because the site is either not built using shopify or the site has not exposed the url") 66 | 67 | _, actualErr := suite.resource.GetProducts(1, 1) 68 | 69 | suite.Require().Equal(expectedErr, actualErr) 70 | } 71 | -------------------------------------------------------------------------------- /internal/services/product_csv_conversion.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "github.com/kishaningithub/shopify-csv-download/internal/models/shopify" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type ProductCSVConversionService interface { 10 | ConvertToCSVFormat(product shopify.Product) []shopify.ProductCSV 11 | } 12 | 13 | type productCSVConversionService struct { 14 | } 15 | 16 | func NewProductCSVConversionService() ProductCSVConversionService { 17 | return &productCSVConversionService{} 18 | } 19 | 20 | func (service *productCSVConversionService) ConvertToCSVFormat(product shopify.Product) []shopify.ProductCSV { 21 | productsInCSV := make([]shopify.ProductCSV, 0, len(product.Variants)) 22 | for _, variant := range product.Variants { 23 | productsInCSV = append(productsInCSV, service.getProductCSVForVariant(product, variant)) 24 | } 25 | productsInCSV = append(productsInCSV, service.getProductCSVForAllImages(product)...) 26 | return productsInCSV 27 | } 28 | 29 | func (service *productCSVConversionService) getProductCSVForAllImages(product shopify.Product) []shopify.ProductCSV { 30 | noOfImagesInProduct := len(product.Images) 31 | productsInCSVForRemainingImages := make([]shopify.ProductCSV, 0, noOfImagesInProduct) 32 | for _, image := range product.Images { 33 | productsInCSVForRemainingImages = append(productsInCSVForRemainingImages, service.getProductCSVForImage(product, image)) 34 | } 35 | return productsInCSVForRemainingImages 36 | } 37 | 38 | func (service *productCSVConversionService) getProductCSVForImage(product shopify.Product, image shopify.Image) shopify.ProductCSV { 39 | return shopify.ProductCSV{ 40 | Handle: product.Handle, 41 | ImageSrc: image.Src, 42 | ImagePosition: strconv.Itoa(image.Position), 43 | } 44 | } 45 | 46 | func (service *productCSVConversionService) getProductCSVForVariant(product shopify.Product, variant shopify.Variant) shopify.ProductCSV { 47 | return shopify.ProductCSV{ 48 | Handle: product.Handle, 49 | Title: product.Title, 50 | BodyHTML: product.BodyHTML, 51 | Vendor: product.Vendor, 52 | Type: product.ProductType, 53 | Tags: strings.Join(product.Tags, ","), 54 | Published: strings.ToUpper(strconv.FormatBool(true)), 55 | Option1Name: service.getOption1Name(product), 56 | Option1Value: variant.Option1, 57 | Option2Name: service.getOption2Name(product, variant), 58 | Option2Value: variant.Option2, 59 | Option3Name: service.getOption3Name(product, variant), 60 | Option3Value: variant.Option3, 61 | VariantSKU: variant.Sku, 62 | VariantGrams: strconv.Itoa(variant.Grams), 63 | VariantInventoryTracker: "shopify", 64 | VariantInventoryQty: service.getVariantInventoryQuantity(variant), 65 | VariantInventoryPolicy: "deny", 66 | VariantFulfillmentService: "manual", 67 | VariantPrice: variant.Price, 68 | VariantCompareAtPrice: variant.CompareAtPrice, 69 | VariantBarcode: variant.Barcode, 70 | VariantRequiresShipping: strings.ToUpper(strconv.FormatBool(variant.RequiresShipping)), 71 | VariantTaxable: strings.ToUpper(strconv.FormatBool(variant.Taxable)), 72 | GiftCard: strings.ToUpper(strconv.FormatBool(false)), 73 | } 74 | } 75 | 76 | func (service *productCSVConversionService) getOption1Name(product shopify.Product) string { 77 | return product.Options[0].Name 78 | } 79 | 80 | func (service *productCSVConversionService) getOption2Name(product shopify.Product, variant shopify.Variant) string { 81 | if len(variant.Option2) > 0 { 82 | return product.Options[1].Name 83 | } 84 | return "" 85 | } 86 | 87 | func (service *productCSVConversionService) getOption3Name(product shopify.Product, variant shopify.Variant) string { 88 | if len(variant.Option3) > 0 { 89 | return product.Options[2].Name 90 | } 91 | return "" 92 | } 93 | 94 | func (service *productCSVConversionService) getVariantInventoryQuantity(variant shopify.Variant) string { 95 | variantInventoryQuantity := "0" 96 | if variant.Available { 97 | variantInventoryQuantity = "1" 98 | } 99 | return variantInventoryQuantity 100 | } 101 | -------------------------------------------------------------------------------- /internal/services/product_csv_conversion_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import ( 4 | "github.com/kishaningithub/shopify-csv-download/internal/models/shopify" 5 | "github.com/kishaningithub/shopify-csv-download/internal/services" 6 | "github.com/stretchr/testify/suite" 7 | "testing" 8 | ) 9 | 10 | var ( 11 | _ suite.SetupTestSuite = (*ProductCSVConversionServiceTestSuite)(nil) 12 | ) 13 | 14 | type ProductCSVConversionServiceTestSuite struct { 15 | suite.Suite 16 | productCSVConversionService services.ProductCSVConversionService 17 | } 18 | 19 | func TestProductCSVConversionServiceTestSuite(t *testing.T) { 20 | suite.Run(t, new(ProductCSVConversionServiceTestSuite)) 21 | } 22 | 23 | func (suite *ProductCSVConversionServiceTestSuite) SetupTest() { 24 | suite.productCSVConversionService = services.NewProductCSVConversionService() 25 | } 26 | 27 | func (suite *ProductCSVConversionServiceTestSuite) TestProductConversionForAProductWithAllInformation() { 28 | productWithAllInformation := shopify.Product{ 29 | Title: "title", 30 | Handle: "handle", 31 | BodyHTML: "bodyHTML", 32 | Vendor: "vendor", 33 | ProductType: "productType", 34 | Tags: []string{"tag1", "tag2"}, 35 | Variants: []shopify.Variant{ 36 | { 37 | Option1: "option1Value1", 38 | Option2: "option2Value1", 39 | Option3: "option3Value1", 40 | Sku: "variant1Sku", 41 | RequiresShipping: true, 42 | Taxable: true, 43 | Barcode: "variant1Barcode", 44 | Available: true, 45 | Price: "1000", 46 | Grams: 100, 47 | CompareAtPrice: "10000", 48 | }, 49 | { 50 | Option1: "option1Value2", 51 | Option2: "option2Value2", 52 | Option3: "option3Value2", 53 | Sku: "variant2Sku", 54 | RequiresShipping: true, 55 | Taxable: true, 56 | Barcode: "variant2Barcode", 57 | Available: true, 58 | Price: "2000", 59 | Grams: 200, 60 | CompareAtPrice: "20000", 61 | }, 62 | }, 63 | Images: []shopify.Image{ 64 | { 65 | Position: 1, 66 | Src: "image1Src", 67 | }, 68 | { 69 | Position: 2, 70 | Src: "image2Src", 71 | }, 72 | { 73 | Position: 3, 74 | Src: "image3Src", 75 | }, 76 | }, 77 | Options: []shopify.Option{ 78 | { 79 | Name: "Option 1 name", 80 | Position: 1, 81 | Values: []string{ 82 | "option1Value1", 83 | "option1Value2", 84 | "option1Value3", 85 | }, 86 | }, 87 | { 88 | Name: "Option 2 name", 89 | Position: 2, 90 | Values: []string{ 91 | "option2Value1", 92 | "option2Value2", 93 | "option2Value3", 94 | }, 95 | }, 96 | { 97 | Name: "Option 3 name", 98 | Position: 3, 99 | Values: []string{ 100 | "option3Value1", 101 | "option3Value2", 102 | "option3Value3", 103 | }, 104 | }, 105 | }, 106 | } 107 | 108 | expectedProductCSV := []shopify.ProductCSV{ 109 | // Variant 1 110 | { 111 | Handle: "handle", 112 | Title: "title", 113 | BodyHTML: "bodyHTML", 114 | Vendor: "vendor", 115 | Type: "productType", 116 | Tags: "tag1,tag2", 117 | Published: "TRUE", 118 | Option1Name: "Option 1 name", 119 | Option1Value: "option1Value1", 120 | Option2Name: "Option 2 name", 121 | Option2Value: "option2Value1", 122 | Option3Name: "Option 3 name", 123 | Option3Value: "option3Value1", 124 | VariantSKU: "variant1Sku", 125 | VariantGrams: "100", 126 | VariantInventoryTracker: "shopify", 127 | VariantInventoryQty: "1", 128 | VariantInventoryPolicy: "deny", 129 | VariantFulfillmentService: "manual", 130 | VariantPrice: "1000", 131 | VariantCompareAtPrice: "10000", 132 | VariantRequiresShipping: "TRUE", 133 | VariantTaxable: "TRUE", 134 | VariantBarcode: "variant1Barcode", 135 | GiftCard: "FALSE", 136 | }, 137 | // Variant 2 138 | { 139 | Handle: "handle", 140 | Title: "title", 141 | BodyHTML: "bodyHTML", 142 | Vendor: "vendor", 143 | Type: "productType", 144 | Tags: "tag1,tag2", 145 | Published: "TRUE", 146 | Option1Name: "Option 1 name", 147 | Option1Value: "option1Value2", 148 | Option2Name: "Option 2 name", 149 | Option2Value: "option2Value2", 150 | Option3Name: "Option 3 name", 151 | Option3Value: "option3Value2", 152 | VariantSKU: "variant2Sku", 153 | VariantGrams: "200", 154 | VariantInventoryTracker: "shopify", 155 | VariantInventoryQty: "1", 156 | VariantInventoryPolicy: "deny", 157 | VariantFulfillmentService: "manual", 158 | VariantPrice: "2000", 159 | VariantCompareAtPrice: "20000", 160 | VariantRequiresShipping: "TRUE", 161 | VariantTaxable: "TRUE", 162 | VariantBarcode: "variant2Barcode", 163 | GiftCard: "FALSE", 164 | }, 165 | // Images 166 | { 167 | Handle: "handle", 168 | ImageSrc: "image1Src", 169 | ImagePosition: "1", 170 | }, 171 | { 172 | Handle: "handle", 173 | ImageSrc: "image2Src", 174 | ImagePosition: "2", 175 | }, 176 | { 177 | Handle: "handle", 178 | ImageSrc: "image3Src", 179 | ImagePosition: "3", 180 | }, 181 | } 182 | 183 | actualProductCSV := suite.productCSVConversionService.ConvertToCSVFormat(productWithAllInformation) 184 | 185 | suite.Require().Equal(expectedProductCSV, actualProductCSV) 186 | } 187 | 188 | func (suite *ProductCSVConversionServiceTestSuite) TestProductConversionForAProductWithNoVariants() { 189 | productWithAllInformation := shopify.Product{ 190 | Title: "title", 191 | Handle: "handle", 192 | BodyHTML: "bodyHTML", 193 | Vendor: "vendor", 194 | ProductType: "productType", 195 | Tags: []string{"tag1", "tag2"}, 196 | Variants: []shopify.Variant{ 197 | { 198 | Title: "variant1Title", 199 | Option1: "Default Title", 200 | Sku: "sku", 201 | RequiresShipping: true, 202 | Taxable: true, 203 | Barcode: "barcode", 204 | Available: true, 205 | Price: "1000", 206 | Grams: 100, 207 | CompareAtPrice: "10000", 208 | }, 209 | }, 210 | Options: []shopify.Option{ 211 | { 212 | Name: "Title", 213 | Position: 1, 214 | Values: []string{ 215 | "Default Title", 216 | }, 217 | }, 218 | }, 219 | } 220 | 221 | expectedProductCSV := []shopify.ProductCSV{ 222 | { 223 | Handle: "handle", 224 | Title: "title", 225 | BodyHTML: "bodyHTML", 226 | Vendor: "vendor", 227 | Type: "productType", 228 | Tags: "tag1,tag2", 229 | Published: "TRUE", 230 | Option1Name: "Title", 231 | Option1Value: "Default Title", 232 | VariantSKU: "sku", 233 | VariantGrams: "100", 234 | VariantInventoryTracker: "shopify", 235 | VariantInventoryQty: "1", 236 | VariantInventoryPolicy: "deny", 237 | VariantFulfillmentService: "manual", 238 | VariantPrice: "1000", 239 | VariantCompareAtPrice: "10000", 240 | VariantRequiresShipping: "TRUE", 241 | VariantTaxable: "TRUE", 242 | VariantBarcode: "barcode", 243 | GiftCard: "FALSE", 244 | }, 245 | } 246 | 247 | actualProductCSV := suite.productCSVConversionService.ConvertToCSVFormat(productWithAllInformation) 248 | 249 | suite.Require().Equal(expectedProductCSV, actualProductCSV) 250 | } 251 | -------------------------------------------------------------------------------- /internal/services/product_retrieval.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "github.com/kishaningithub/shopify-csv-download/internal/models/shopify" 5 | "github.com/kishaningithub/shopify-csv-download/internal/resources" 6 | ) 7 | 8 | type ProductsRetrievalService interface { 9 | RetrieveAllProducts(chan<- shopify.Product) error 10 | } 11 | 12 | type productsRetrievalService struct { 13 | shopifyResource resources.ShopifyResource 14 | } 15 | 16 | func NewProductsRetrievalService(shopifyResource resources.ShopifyResource) ProductsRetrievalService { 17 | return &productsRetrievalService{ 18 | shopifyResource: shopifyResource, 19 | } 20 | } 21 | 22 | func (service *productsRetrievalService) RetrieveAllProducts(products chan<- shopify.Product) error { 23 | maxRecordsPerPage := 250 24 | for pageNo := 1; ; pageNo++ { 25 | productsResponse, err := service.shopifyResource.GetProducts(maxRecordsPerPage, pageNo) 26 | if err != nil { 27 | close(products) 28 | return err 29 | } 30 | if len(productsResponse.Products) == 0 { 31 | close(products) 32 | return nil 33 | } 34 | for _, product := range productsResponse.Products { 35 | products <- product 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/services/products_csv_writer.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "github.com/gocarina/gocsv" 6 | "github.com/kishaningithub/shopify-csv-download/internal/models/shopify" 7 | "github.com/kishaningithub/shopify-csv-download/pkg/progress" 8 | "golang.org/x/sync/errgroup" 9 | "io" 10 | ) 11 | 12 | type ProductsCSVWriterService interface { 13 | DownloadAllProducts(out io.Writer) error 14 | DownloadAllProductsUpdatingProgressState(out io.Writer, progressState chan<- progress.State) error 15 | } 16 | 17 | type productsCSVWriterService struct { 18 | productCSVConversionService ProductCSVConversionService 19 | productsRetrievalService ProductsRetrievalService 20 | } 21 | 22 | func NewProductsCSVWriterService(productCSVConversionService ProductCSVConversionService, productsRetrievalService ProductsRetrievalService) ProductsCSVWriterService { 23 | return &productsCSVWriterService{ 24 | productCSVConversionService: productCSVConversionService, 25 | productsRetrievalService: productsRetrievalService, 26 | } 27 | } 28 | 29 | func (service *productsCSVWriterService) DownloadAllProducts(out io.Writer) error { 30 | return service.downloadAllProductsUpdatingProgressStateIfRequired(out, nil) 31 | } 32 | 33 | func (service *productsCSVWriterService) DownloadAllProductsUpdatingProgressState(out io.Writer, progressState chan<- progress.State) error { 34 | return service.downloadAllProductsUpdatingProgressStateIfRequired(out, progressState) 35 | } 36 | 37 | func (service *productsCSVWriterService) downloadAllProductsUpdatingProgressStateIfRequired(out io.Writer, progressState chan<- progress.State) error { 38 | currentProgressState := progress.State{ 39 | NoOfProductsDownloaded: 0, 40 | NoOfProductsConvertedAsCSV: 0, 41 | } 42 | csvWriter := make(chan interface{}, 1000) 43 | operation, _ := errgroup.WithContext(context.Background()) 44 | operation.Go(func() error { 45 | return gocsv.MarshalChan(csvWriter, gocsv.DefaultCSVWriter(out)) 46 | }) 47 | products := make(chan shopify.Product, 1000) 48 | operation.Go(func() error { 49 | return service.productsRetrievalService.RetrieveAllProducts(products) 50 | }) 51 | for product := range products { 52 | if progressState != nil { 53 | currentProgressState.NoOfProductsDownloaded++ 54 | progressState <- currentProgressState 55 | } 56 | productCSVs := service.productCSVConversionService.ConvertToCSVFormat(product) 57 | if progressState != nil { 58 | currentProgressState.NoOfProductsConvertedAsCSV++ 59 | progressState <- currentProgressState 60 | } 61 | for _, productCSV := range productCSVs { 62 | csvWriter <- productCSV 63 | } 64 | } 65 | close(csvWriter) 66 | return operation.Wait() 67 | } 68 | -------------------------------------------------------------------------------- /pkg/products/writer.go: -------------------------------------------------------------------------------- 1 | package products 2 | 3 | import ( 4 | "fmt" 5 | "github.com/kishaningithub/shopify-csv-download/internal/dependency_injection" 6 | "github.com/kishaningithub/shopify-csv-download/pkg/progress" 7 | "io" 8 | "net/url" 9 | ) 10 | 11 | // SaveAsImportableCSV saves products from the given url to given writer 12 | func SaveAsImportableCSV(shopifyStoreUrlString string, out io.Writer) error { 13 | shopifyStoreUrl, err := url.ParseRequestURI(shopifyStoreUrlString) 14 | if err != nil { 15 | return fmt.Errorf("invalid url %s: %w", shopifyStoreUrlString, err) 16 | } 17 | productCSVWriter := dependency_injection.ConstructRequiredObjects(*shopifyStoreUrl).ProductsCSVWriterService 18 | return productCSVWriter.DownloadAllProducts(out) 19 | } 20 | 21 | // SaveAsImportableCSVNotifyingProgressState saves products from the given url to given writer and notifies the progress state 22 | func SaveAsImportableCSVNotifyingProgressState(shopifyStoreUrl url.URL, out io.Writer, onProgressHandler progress.Handler) error { 23 | progressStates := make(chan progress.State, 1000) 24 | go func() { 25 | for progressState := range progressStates { 26 | onProgressHandler(progressState) 27 | } 28 | }() 29 | productCSVWriter := dependency_injection.ConstructRequiredObjects(shopifyStoreUrl).ProductsCSVWriterService 30 | return productCSVWriter.DownloadAllProductsUpdatingProgressState(out, progressStates) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/products/writer_integration_test.go: -------------------------------------------------------------------------------- 1 | package products_test 2 | 3 | import ( 4 | "github.com/jarcoal/httpmock" 5 | "github.com/kishaningithub/shopify-csv-download/pkg/products" 6 | "github.com/stretchr/testify/suite" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | var ( 12 | _ suite.SetupTestSuite = (*WriterTestSuite)(nil) 13 | _ suite.TearDownTestSuite = (*WriterTestSuite)(nil) 14 | ) 15 | 16 | type WriterTestSuite struct { 17 | suite.Suite 18 | } 19 | 20 | func TestWriterTestSuite(t *testing.T) { 21 | suite.Run(t, new(WriterTestSuite)) 22 | } 23 | 24 | func (suite *WriterTestSuite) SetupTest() { 25 | httpmock.Activate() 26 | } 27 | 28 | func (suite *WriterTestSuite) TearDownTest() { 29 | httpmock.DeactivateAndReset() 30 | } 31 | 32 | func (suite *WriterTestSuite) TestSaveAsImportableCSV_ShouldWriteCSVToGivenWriter() { 33 | httpmock.RegisterResponder("GET", "https://example.com/products.json?limit=250&page=1", 34 | httpmock.NewStringResponder(200, ` 35 | { 36 | "products": [ 37 | { 38 | "handle": "awesome-product", 39 | "variants": [ 40 | { 41 | "id": 31491876913239, 42 | "title": "Default Title", 43 | "option1": "Default Title" 44 | } 45 | ], 46 | "options": [ 47 | { 48 | "name": "Title", 49 | "values": [ 50 | "Default Title" 51 | ] 52 | } 53 | ] 54 | } 55 | ] 56 | } 57 | `)) 58 | httpmock.RegisterResponder("GET", "https://example.com/products.json?limit=250&page=2", 59 | httpmock.NewStringResponder(200, `{"products":[]}`)) 60 | var sb strings.Builder 61 | 62 | err := products.SaveAsImportableCSV("https://example.com", &sb) 63 | 64 | suite.Require().NoError(err) 65 | noOfLinesInCSV := strings.Count(sb.String(), "\n") 66 | suite.Require().Equal(2, noOfLinesInCSV, sb.String()) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/progress/progress_state.go: -------------------------------------------------------------------------------- 1 | package progress 2 | 3 | // State comprises of progress state that can be used for displaying the consumer the current state of the operation 4 | type State struct { 5 | NoOfProductsDownloaded int 6 | NoOfProductsConvertedAsCSV int 7 | } 8 | 9 | // Handler used as callback to process the progress state as the process happens 10 | type Handler func(state State) 11 | --------------------------------------------------------------------------------