using web::WebReq
using afIoc
** (Service) - A Route Handler that maps URLs to files on the file system.
**
** Suppose your project has this directory structure:
**
** pre>
** myProj/
** |-- fan/
** |-- test/
** `-- etc
** `-- static-web/
** |-- css/
** | `-- app.css
** |-- images/
** | `-- logo.png
** `-- scripts/
** <pre
**
** Then to map the 'css/' dir add the following to 'AppModule':
**
** pre>
** @Contribute { serviceType=FileHandler# }
** static Void contributeFileHandler(Configuration conf) {
** conf[`/stylesheets/`] = `etc/static-web/css/`.toFile
** }
** <pre
**
** Browsers may then access 'app.css' with the URL '/stylesheets/app.css'.
**
** Rather than hardcoding '/stylesheets/app.css' in the HTML, it is better to generate a client URL from 'FileHandler'.
**
** url := fileHandler.fromLocalUrl(`/stylesheets/app.css`).clientUrl
**
** Most of the time 'url' will be the same as the hardcoded URL but it has the added benefit of:
** - [Failing fast]`#failFast` if the file does not exist
** - generating correct URLs in non-root WebMods
** - using asset caching strategies
**
** The generated 'clientUrl' contains any extra 'WebMod' path segments required to reach the 'BedSheet WebMod'.
** It also contains path segments as provided by any asset caching strategies, such as [Cold Feet]`http://www.fantomfactory.org/pods/afColdFeet`.
**
**
**
** Serve All Root Directories [#serveAllDirs]
** ==========================================
** Using the above example, extra config would need to be added to serve the 'images/' and the 'scripts/' directories.
** This is not ideal. So to serve all the files and directories under 'etc/static-web/' add config for the root URL:
**
** conf[`/`] = `etc/static-web/`.toFile
**
** This way everything under `etc/static-web/` is served as is. Example, 'logo.png' is accessed with the URL '/images/logo.png'.
**
**
**
** Fail Fast [#failFast]
** =====================
** An understated advantage of using 'FileHandler' to generate client URLs for your assets is that it fails fast.
**
** Should an asset not exist on the file system (due to a bodged rename, a case sensitivity issue, or other) then 'FileHandler' will throw an Err on the server when the client URL is constructed.
** This allows your web tests to quickly pick up these tricky errors.
**
** The lesser appealing alternative is for the incorrect URL to be served to the browser which on following, will subsequently receive a '404 - Not Found'.
** While this may not seem a big deal, these errors often go unnoticed and easily find their way into production.
**
**
**
** Precedence with Other Routes [#RoutePrecedence]
** ===============================================
** The 'FileHandler' directory mappings are automatically added to the `Routes` service on startup.
** That means it is possible to specify a 'Route' URL with more than one handler; a custom handler *and* this 'FileHandler'.
** With a bit of configuration it is possible to specify which takes precedence.
**
** The 'FileHandler' route contributions are set with the ID 'afBedSheet.fileHander', so when 'Route' precedence is important, use it in your config:
**
** pre>
** @Contribute { serviceType=Routes# }
** static Void contributeRoutes(Configuration config) {
**
** // this Route will be served in place of the file 'url1.txt'
** config.set("beforeExample", Route(`/url1.txt`, ...)).before("afBedSheet.fileHandler")
**
** // this Route will be served if there is no file called 'url2.txt'
** config.set("afterExample", Route(`/url2.txt`, ...)).after("afBedSheet.fileHandler")
** }
** <pre
**
** @uses Configuration of 'Uri:File'
const mixin FileHandler {
** Returns the map of URL to directory mappings
abstract Uri:File directoryMappings()
** The (boring) Route handler method.
** Returns a 'FileAsset' as mapped from the HTTP request URL or null if not found.
abstract FileAsset? serviceRoute(Uri remainingUrl)
** Given a local URL (a simple URL relative to the WebMod), this returns a corresponding (cached) 'FileAsset'.
**
** url := fileHandler.fromLocalUrl(`/stylesheets/app.css`).clientUrl
**
** Throws 'ArgErr' if the URL is not mapped.
** Throws 'ArgErr' if checked and the file does not exist.
abstract FileAsset fromLocalUrl(Uri localUrl, Bool checked := true)
** Given a file on the server, this returns a corresponding (cached) 'FileAsset'.
**
** Throws 'ArgErr' if the file directory is not mapped.
** Throws 'ArgErr' if checked and the URL does not exist.
abstract FileAsset fromServerFile(File serverFile, Bool checked := true)
** Finds the directory mapping that best fits the given local URL, or 'null' if not found.
@NoDoc // Experimental advanced use - see Duvet
abstract Uri? findMappingFromLocalUrl(Uri localUrl)
}
internal const class FileHandlerImpl : FileHandler {
@Inject private const HttpRequest? httpRequest // nullable for unit tests
@Inject private const FileAssetCache fileCache
override const Uri:File directoryMappings
new make(Uri:File dirMappings, |This|? in) {
in?.call(this)
// verify file and uri mappings, normalise the files
this.directoryMappings = dirMappings.map |file, uri -> File| {
if (!file.exists)
throw BedSheetErr(BsErrMsgs.fileNotFound(file))
if (!file.isDir)
throw BedSheetErr(BsErrMsgs.fileIsNotDirectory(file))
if (!uri.isPathOnly)
throw BedSheetErr(BsErrMsgs.urlMustBePathOnly(uri, `/foo/bar/`))
if (!uri.isPathAbs)
throw BedSheetErr(BsErrMsgs.urlMustStartWithSlash(uri, `/foo/bar/`))
if (!uri.isDir)
throw BedSheetErr(BsErrMsgs.urlMustEndWithSlash(uri, `/foo/bar/`))
return file.normalize
}
}
override FileAsset? serviceRoute(Uri remainingUri) {
try {
// use pathStr to knockout any unwanted query str
return fromLocalUrl(httpRequest.url.pathStr.toUri)
} catch
// don't bother making fromLocalUrl() checked, it's too much work for a 404!
// null means that 'Routes' didn't process the request, so it continues down the pipeline.
return null
}
override Uri? findMappingFromLocalUrl(Uri localUrl) {
Utils.validateLocalUrl(localUrl, `/css/myStyles.css`)
// TODO: what if 2 dirs map to the same url at the same level?
// match the deepest uri
prefixes:= directoryMappings.keys.findAll { localUrl.toStr.startsWith(it.toStr) }
prefix := prefixes.size == 1 ? prefixes.first : prefixes.sort |u1, u2 -> Int| { u1.path.size <=> u2.path.size }.last
return prefix
}
override FileAsset fromLocalUrl(Uri localUrl, Bool checked := true) {
prefix := findMappingFromLocalUrl(localUrl)
?: throw BedSheetNotFoundErr(BsErrMsgs.fileHandler_urlNotMapped(localUrl), directoryMappings.keys)
// We pass 'false' to prevent Errs being thrown if the uri is a dir but doesn't end in '/'.
// The 'false' appends a '/' automatically - it's nicer web behaviour
remaining := localUrl.getRange(prefix.path.size..-1).relTo(`/`)
file := directoryMappings[prefix].plus(remaining, false)
return fromServerFile(file, checked)
}
override FileAsset fromServerFile(File file, Bool checked := true) {
fileCache.getOrAddOrUpdate(file) |File f->FileAsset| {
if (file.uri.isDir)
throw ArgErr(BsErrMsgs.fileIsDirectory(file))
if (!file.exists && checked)
throw ArgErr(BsErrMsgs.fileNotFound(file))
fileUri := file.normalize.uri.toStr
prefix := (Uri?) directoryMappings.eachWhile |af, uri->Uri?| { fileUri.startsWith(af.uri.toStr) ? uri : null }
if (prefix == null)
throw BedSheetNotFoundErr(BsErrMsgs.fileHandler_fileNotMapped(file), directoryMappings.vals.map { it.osPath })
matchedFile := directoryMappings[prefix]
remaining := fileUri[matchedFile.uri.toStr.size..-1]
localUrl := prefix + remaining.toUri
clientUrl := fileCache.toClientUrl(localUrl, file)
return FileAsset(f, localUrl, clientUrl)
}
}
}