File: ./doc.go
   1 /*
   2 # General IO
   3 
   4 Convenient functionality not present in stdlib package io: since it depends on
   5 stdlib package net/http, importing this package may increase the size of your
   6 apps by a few megabytes.
   7 */
   8 package genio

     File: ./go.mod
   1 module genio
   2 
   3 go 1.16

     File: ./readers.go
   1 package genio
   2 
   3 import (
   4     "bytes"
   5     "encoding/base64"
   6     "io"
   7     "mime"
   8     "net/http"
   9     "os"
  10     "path/filepath"
  11     "strings"
  12 )
  13 
  14 // empty is an empty io.ReadCloser, a counterpart to io.Discard
  15 type empty struct{}
  16 
  17 func (e empty) Read(buf []byte) (n int, err error) {
  18     return 0, io.EOF
  19 }
  20 
  21 func (e empty) Close() error {
  22     return nil
  23 }
  24 
  25 // ReaderInfo allows callbacks given to func With to access various metadata.
  26 type ReaderInfo struct {
  27     // Path is filename/URI given, and is empty when dealing with stdin.
  28     Path string
  29 
  30     // File is nil when the reader is from a web request. You probably want to
  31     // use the reader returned from method Reader instead of this field, which
  32     // is available in case you really need more control.
  33     File *os.File
  34 
  35     // Response is nil when the reader is from a file/stdin. You probably want
  36     // to use the reader returned from method Reader instead of this field, which
  37     // is available in case you really need more control.
  38     Response *http.Response
  39 
  40     // mime is the mime-type of the data source, and is empty when dealing with
  41     // files or the standard input.
  42     mime string
  43 }
  44 
  45 func ReaderInfoFrom(v interface{}) ReaderInfo {
  46     if f, ok := v.(*os.File); ok {
  47         return ReaderInfo{Path: f.Name(), File: f}
  48     }
  49     if r, ok := v.(*http.Response); ok {
  50         mime := r.Header.Get("Content-Type") // method Get is case-insensitive
  51         return ReaderInfo{Path: r.Request.RequestURI, Response: r, mime: mime}
  52     }
  53     return ReaderInfo{}
  54 }
  55 
  56 // Reader returns the reader associated with a ReaderInfo: nil means either
  57 // there was an error, or a data URI was used.
  58 func (info ReaderInfo) Reader() io.Reader {
  59     if info.File != nil {
  60         return info.File
  61     }
  62     if info.Response != nil {
  63         return info.Response.Body
  64     }
  65     return nil
  66 }
  67 
  68 // Type determines the data-format associated with the reader: when a MIME type
  69 // is available that's the result, otherwise it's a file extension with a leading
  70 // dot.
  71 func (info ReaderInfo) Type() string {
  72     if info.mime != "" {
  73         return info.mime
  74     }
  75     ext := filepath.Ext(info.Path)
  76     if kind := mime.TypeByExtension(ext); kind != "" {
  77         return kind
  78     }
  79     return strings.ToLower(ext)
  80 }
  81 
  82 // Ext finds out the filename extension: if filename suggests gz compression,
  83 // a gzip-style double-extension string may be returned.
  84 func (info ReaderInfo) Ext() string {
  85     if len(info.Path) < 3 {
  86         return filepath.Ext(info.Path)
  87     }
  88 
  89     i := strings.LastIndexByte(info.Path, '.')
  90     if i < 0 {
  91         // no extension at all
  92         return ""
  93     }
  94 
  95     path := info.Path
  96     // check for gzip-style double-extension
  97     if last3 := path[len(path)-3:]; strings.EqualFold(last3, ".gz") {
  98         i = strings.LastIndexByte(path[:len(path)-3], '.')
  99         if i < 0 {
 100             // just a .gz extension
 101             return last3
 102         }
 103         // got a gzip-style double-extension
 104         return path[i:]
 105     }
 106 
 107     // got a regular single-extension
 108     return path[i:]
 109 }
 110 
 111 func isFromWeb(s string) bool {
 112     return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://")
 113 }
 114 
 115 // Open is a more general alternative to os.Open from the stdlib, which also
 116 // handles HTTP(S) URIs when the name starts accordingly, and standard-input,
 117 // when the name is either empty or a dash.
 118 //
 119 // As when using os.Open, call the Close method on the io.Reader returned when
 120 // you're done with it. If the reader returned is backed by os.Stdin, calling
 121 // Close on that reader won't actually do anything.
 122 //
 123 // The information object returned should give you a non-empty MIME type, by
 124 // using a web-request's Content-Type header. The MIME type will be empty when
 125 // reading from a file or stdin.
 126 //
 127 // # Examples
 128 //
 129 //  rc, info, err := genio.Open(cfg.Source)
 130 //
 131 //  if err != nil {
 132 //      return err
 133 //  }
 134 //
 135 //  defer rc.Close()
 136 //
 137 //  t := info.Type()
 138 //  if strings.HasSuffix(t, "/json") || strings.HasSuffix(t, ".json") {
 139 //      return fmt.Errorf("this app doesn't support JSON data input")
 140 //  }
 141 func Open(pathOrURI string) (rc io.ReadCloser, info ReaderInfo, err error) {
 142     // handle stdin
 143     if pathOrURI == "" || pathOrURI == "-" {
 144         return io.NopCloser(os.Stdin), ReaderInfo{File: os.Stdin}, nil
 145     }
 146 
 147     // handle web requests
 148     if isFromWeb(pathOrURI) {
 149         r, err := http.Get(pathOrURI)
 150         if err != nil {
 151             return nil, ReaderInfo{Path: pathOrURI}, err
 152         }
 153 
 154         mime := r.Header.Get("Content-Type") // method Get is case-insensitive
 155         ri := ReaderInfo{Path: pathOrURI, mime: mime, Response: r}
 156         return r.Body, ri, nil
 157     }
 158 
 159     // handle empty data URIs
 160     if pathOrURI == "data:," {
 161         return empty{}, ReaderInfo{Path: pathOrURI}, nil
 162     }
 163 
 164     // handle base64-encoded data URIs
 165     if mime, data, ok := splitBase64(pathOrURI); ok {
 166         b, err := base64.URLEncoding.DecodeString(data)
 167         if err != nil {
 168             return nil, ReaderInfo{Path: pathOrURI, mime: mime}, err
 169         }
 170         ri := ReaderInfo{Path: pathOrURI, mime: mime}
 171         return io.NopCloser(bytes.NewReader(b)), ri, nil
 172     }
 173 
 174     // handle file-protocol URIs simply by ignoring the protocol part
 175     pathOrURI = strings.TrimPrefix(pathOrURI, "file://")
 176 
 177     // handle files
 178     r, err := os.Open(pathOrURI)
 179     if err != nil {
 180         return nil, ReaderInfo{Path: pathOrURI}, err
 181     }
 182     return r, ReaderInfo{Path: pathOrURI, File: r}, nil
 183 }
 184 
 185 // splitBase64 tries to split a base64-encoded data string into its MIME-type
 186 // and payload parts
 187 func splitBase64(s string) (mime, data string, ok bool) {
 188     if !strings.HasPrefix(s, "data:") {
 189         return "", "", false
 190     }
 191 
 192     s = s[len("data:"):]
 193     if i := strings.Index(s, ";base64,"); i >= 0 {
 194         return s[:i], s[i+len(";base64,"):], true
 195     }
 196     return "", "", false
 197 }
 198 
 199 // ReadFrom is a generalization of os.ReadFile which also supports URIs.
 200 func ReadFrom(pathOrURI string) (data []byte, info ReaderInfo, err error) {
 201     rc, info, err := Open(pathOrURI)
 202     if err != nil {
 203         return nil, info, err
 204     }
 205     defer rc.Close()
 206 
 207     // info.File = nil
 208     // info.Response = nil
 209     data, err = io.ReadAll(rc)
 210     if err != nil {
 211         return nil, info, err
 212     }
 213     return data, info, nil
 214 }