Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Accept request URL in lieu of configuration file #728

Merged
merged 5 commits into from
Aug 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,20 @@

## 2.7.0

### Packaging

* Xcode 13.0 or above and Swift 5.5 or above are now required to build MapboxDirections from source. ([#725](https://github.com/mapbox/mapbox-directions-swift/pull/725), [#727](https://github.com/mapbox/mapbox-directions-swift/pull/727))

### Command line tool

* Removed the `--config` option. Instead, pass in either the path to a JSON configuration file or the full URL to a Mapbox Directions API or Mapbox Map Matching API request. ([#726](https://github.com/mapbox/mapbox-directions-swift/pull/726))
* When the `MAPBOX_ACCESS_TOKEN` environment variable is unset, the tool exits with an error code instead of crashing. ([#728](https://github.com/mapbox/mapbox-directions-swift/pull/728))
* The tool now connects to the API endpoint in the `MAPBOX_HOST` environment variable, if specified. ([#728](https://github.com/mapbox/mapbox-directions-swift/pull/728))

### Other changes

* Added `Waypoint.allowsSnappingToStaticallyClosedRoad` property to allow snapping the waypoint’s location to a statically (long-term) closed part of a road. ([#721](https://github.com/mapbox/mapbox-directions-swift/pull/721))
* `RouteOptions(url:)` now returns `nil` if given a Mapbox Map Matching API request URL, and `MatchOptions(url:)` returns `nil` if given a Mapbox Directions API request URL. ([#728](https://github.com/mapbox/mapbox-directions-swift/pull/728))

## v2.6.0

Expand Down
18 changes: 14 additions & 4 deletions CommandLineTool.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,26 @@ To run (and build if it wasn't yet) `MapboxDirectionsCLI` and see usage:

To run the `MapboxDirectionsCLI` within Xcode, select the `MapboxDirectionsCLI` target and edit the scheme to include arguments passed at launch.

## Configuration

A [Mapbox access token](https://account.mapbox.com/access-tokens/) is required for some operations. Set the `MAPBOX_ACCESS_TOKEN` environment variable to your access token.

To connect to an API endpoint other than the default Mapbox API endpoint, set the `MAPBOX_HOST` environment variable to the base URL.

## Usage and Recipes

`mapbox-directions-swift` is a useful tool for mobile quality assurance. This tool can be used to verify a response to ensure proper Directions API integration, get a [GPX](https://wikipedia.org/wiki/GPS_Exchange_Format) trace that can be used in the Xcode Simulator, and convert a Directions API request to an Options object.

### Arguments

The sole argument is either:

* The path to a JSON file that contains a serialized `RouteOptions` or `MatchOptions`
* The URL of a Mapbox Directions API or Mapbox Map Matching API request

### Options
`--input`
An optional flag for the filepath to the input JSON. If this flag is not used, `mapbox-directions-swift` will fallback to a Directions API request. To request using specific coordinates, specify coordinates using `--waypoints` or a Directions API request using `--url`.

`--config`
An optional flag for the filepath to the JSON, containing serialized Options data.
An optional flag for the filepath to the input JSON. If this flag is not used, `mapbox-directions-swift` will fallback to a Directions API request.

`--output`
An optional flag for the filepath to save the conversion result. If no filepath is provided, the result will output to the shell. If you want a GPX trace that can be easily uploaded to Xcode, provide an output filepath with this flag.
Expand Down
5 changes: 5 additions & 0 deletions Sources/MapboxDirections/DirectionsOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ open class DirectionsOptions: Codable {
self.init(waypoints: waypoints,
profileIdentifier: profileIdentifier,
queryItems: URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems)

// Distinguish between Directions API and Map Matching API URLs.
guard url.pathComponents.dropLast().joined(separator: "/").hasSuffix(abridgedPath) else {
return nil
}
}


Expand Down
56 changes: 40 additions & 16 deletions Sources/MapboxDirectionsCLI/CodingOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,6 @@ import Turf
import FoundationNetworking
#endif

let accessToken: String? =
ProcessInfo.processInfo.environment["MAPBOX_ACCESS_TOKEN"] ??
UserDefaults.standard.string(forKey: "MBXAccessToken")
let credentials = Credentials(accessToken: accessToken!)
private let directions = Directions(credentials: credentials)

protocol DirectionsResultsProvider {
var directionsResults: [DirectionsResult]? { get }
}
Expand All @@ -28,6 +22,7 @@ class CodingOperation<ResponseType : Codable & DirectionsResultsProvider, Option
// MARK: - Parameters

let options: ProcessingOptions
let credentials: Credentials

// MARK: - Helper methods

Expand Down Expand Up @@ -123,12 +118,17 @@ class CodingOperation<ResponseType : Codable & DirectionsResultsProvider, Option
return interpolatedCoordinates
}

private func requestResponse(_ directionsOptions: OptionsType) -> (Data) {
private func response(fetching directionsOptions: OptionsType) -> (Data) {
let directions = Directions(credentials: credentials)
let url = directions.url(forCalculating: directionsOptions)
return response(fetching: url)
}

private func response(fetching url: URL) -> Data {
let semaphore = DispatchSemaphore(value: 0)

var responseData: Data!

let url = directions.url(forCalculating: directionsOptions)
let urlSession = URLSession(configuration: .ephemeral)

let task = urlSession.dataTask(with: url) { (data, response, error) in
Expand All @@ -146,28 +146,52 @@ class CodingOperation<ResponseType : Codable & DirectionsResultsProvider, Option
return (responseData)
}

init(options: ProcessingOptions) {
init(options: ProcessingOptions, credentials: Credentials) {
self.options = options
self.credentials = credentials
}

// MARK: - Command implementation

func execute() throws {

let config = FileManager.default.contents(atPath: NSString(string: options.configPath).expandingTildeInPath)!
let input: Data

let decoder = JSONDecoder()

let directionsOptions = try decoder.decode(OptionsType.self, from: config)
let directions: Directions
let directionsOptions: OptionsType
let requestURL: URL
if FileManager.default.fileExists(atPath: (options.config as NSString).expandingTildeInPath) {
// Assume the file is a configuration JSON file. Convert it to an options object.
let configData = FileManager.default.contents(atPath: (options.config as NSString).expandingTildeInPath)!
let decoder = JSONDecoder()
directions = Directions(credentials: credentials)
directionsOptions = try decoder.decode(OptionsType.self, from: configData)
} else if let url = URL(string: options.config) {
// Try to convert the URL to an options object.
if let parsedOptions = (RouteOptions(url: url) ?? MatchOptions(url: url)) as? OptionsType {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is why DirectionsOptions(url:) has to return nil when given a URL whose path conflicts with the abridgedPath property. If it doesn’t return nil, then either RouteOptions and MatchOptions would be equally likely to initialize based on the same URL, mooting the coalescing operator.

Maybe it would be cleaner to nix the OptionsType generic parameter in favor of an enumeration with associated values that gets passed into this method, since the two APIs aren’t all that different. But I’ll leave it for now in case another API needs this kind of flexibility.

directionsOptions = parsedOptions
} else {
fatalError("Configuration is not a valid Mapbox Directions API or Mapbox Map Matching API request URL.")
}

// Get credentials from the request URL but fall back to the environment.
var urlWithAccessToken = URLComponents(string: url.absoluteString)!
urlWithAccessToken.queryItems = (urlWithAccessToken.queryItems ?? []) + [.init(name: "access_token", value: self.credentials.accessToken)]
let credentials = Credentials(requestURL: urlWithAccessToken.url!)
Comment on lines +177 to +178
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was pretty annoying: Credentials(url:) calls the required initializer Credentials(accessToken:host:). If the URL doesn’t have the access_token query parameter set, then the required initializer gets a nil access token, resulting in a precondition failure:

precondition(accessToken != nil && !accessToken!.isEmpty, "A Mapbox access token is required. Go to <https://account.mapbox.com/access-tokens/>. In Info.plist, set the MBXAccessToken key to your access token, or use the Directions(accessToken:host:) initializer.")

The code here has no opportunity to fall back to the access token from the environment, so instead it has to proactively fall back inside the URL before initializing the Credentials object.


directions = Directions(credentials: credentials)
} else {
fatalError("Configuration is not a valid JSON configuration file or request URL.")
}

let input: Data
if let inputPath = options.inputPath {
input = FileManager.default.contents(atPath: NSString(string: inputPath).expandingTildeInPath)!
} else {
let response = requestResponse(directionsOptions)
requestURL = directions.url(forCalculating: directionsOptions)
let response = response(fetching: requestURL)
input = response
}

let decoder = JSONDecoder()
decoder.userInfo = [.options: directionsOptions,
.credentials: credentials]

Expand Down
33 changes: 25 additions & 8 deletions Sources/MapboxDirectionsCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import Foundation
import MapboxDirections
import ArgumentParser


struct ProcessingOptions: ParsableArguments {

@Option(name: [.short, .customLong("input")], help: "[Optional] Filepath to the input JSON. If no filepath provided - will fall back to Directions API request using locations in config file.")
var inputPath: String?

@Option(name: [.short, .customLong("config")], help: "Filepath to the JSON, containing serialized Options data.")
var configPath: String
@Argument(help: "Path to a JSON file containing serialized RouteOptions or MatchOptions properties, or the full URL of a Mapbox Directions API or Mapbox Map Matching API request.")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kind of unwieldy as an argument description. A possible future improvement would be to accept multiple configurations, perhaps a mix of files and URLs, and merge them somehow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will you document that possible future improvement?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s track it as tail work in #729.

var config: String

@Option(name: [.short, .customLong("output")], help: "[Optional] Output filepath to save the conversion result. If no filepath provided - will output to the shell.")
var outputPath: String?
Expand All @@ -27,16 +26,34 @@ struct ProcessingOptions: ParsableArguments {
}

struct Command: ParsableCommand {
static var credentials: Credentials {
get throws {
guard let accessToken = ProcessInfo.processInfo.environment["MAPBOX_ACCESS_TOKEN"] ??
UserDefaults.standard.string(forKey: "MBXAccessToken") else {
throw ValidationError("A Mapbox access token is required. Go to <https://account.mapbox.com/access-tokens/>, then set the MAPBOX_ACCESS_TOKEN environment variable to your access token.")
}

let hostURL: URL?
if let host = ProcessInfo.processInfo.environment["MAPBOX_HOST"] ??
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of this environment variable is consistent with the Mapbox Python SDK, which similarly reads credentials from the environment. It differs from the MGLMapboxAPIBaseURL Info.plist key that applications would set, but the naming conventions for environment variables are different than for Info.plist keys anyways.

UserDefaults.standard.string(forKey: "MGLMapboxAPIBaseURL") {
hostURL = URL(string: host)
} else {
hostURL = nil
}

return Credentials(accessToken: accessToken, host: hostURL)
}
}

static var configuration = CommandConfiguration(
commandName: "mapbox-directions-swift",
abstract: "'mapbox-directions-swift' is a command line tool, designed to round-trip an arbitrary, JSON-formatted Directions or Map Matching API response through model objects and back to JSON.",
subcommands: [Match.self, Route.self]
)

fileprivate static func validateInput(_ options: ProcessingOptions) throws {

guard FileManager.default.fileExists(atPath: options.configPath) else {
throw ValidationError("Options JSON file `\(options.configPath)` does not exist.")
if !FileManager.default.fileExists(atPath: (options.config as NSString).expandingTildeInPath) && URL(string: options.config) == nil {
throw ValidationError("Configuration is a nonexistent file or invalid request URL: \(options.config)")
}
}
}
Expand All @@ -54,7 +71,7 @@ extension Command {
}

mutating func run() throws {
try CodingOperation<MapMatchingResponse, MatchOptions>(options: options).execute()
try CodingOperation<MapMatchingResponse, MatchOptions>(options: options, credentials: try credentials).execute()
}
}
}
Expand All @@ -72,7 +89,7 @@ extension Command {
}

mutating func run() throws {
try CodingOperation<RouteResponse, RouteOptions>(options: options).execute()
try CodingOperation<RouteResponse, RouteOptions>(options: options, credentials: try credentials).execute()
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion Tests/MapboxDirectionsTests/MatchOptionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class MatchOptionsTests: XCTestCase {
XCTAssertEqual(unarchivedOptions.resamplesTraces, options.resamplesTraces)
}

func testURLCoding() {
func testURLCoding() throws {

let originalOptions = testMatchOptions
originalOptions.resamplesTraces = true
Expand Down Expand Up @@ -62,6 +62,10 @@ class MatchOptionsTests: XCTestCase {

XCTAssertEqual(decodedOptions.profileIdentifier, originalOptions.profileIdentifier)
XCTAssertEqual(decodedOptions.resamplesTraces, originalOptions.resamplesTraces)

let matchURL = try XCTUnwrap(URL(string: "https://api.mapbox.com/matching/v5/mapbox/driving/-121.913565,37.331832;-121.916282,37.328707.json"))
XCTAssertNotNil(MatchOptions(url: matchURL))
XCTAssertNil(RouteOptions(url: matchURL))
}

// MARK: API name-handling tests
Expand Down
6 changes: 5 additions & 1 deletion Tests/MapboxDirectionsTests/RouteOptionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class RouteOptionsTests: XCTestCase {
XCTAssertEqual(unarchivedOptions.maximumHeight, routeOptions.maximumHeight)
}

func testURLCoding() {
func testURLCoding() throws {

let originalOptions = testRouteOptions
originalOptions.includesAlternativeRoutes = true
Expand Down Expand Up @@ -191,6 +191,10 @@ class RouteOptionsTests: XCTestCase {
XCTAssertNil(decodedOptions.arriveBy)
// URL encoding skips seconds, so we check that dates are within 1 minute delta
XCTAssertTrue(abs(decodedOptions.departAt!.timeIntervalSince(originalOptions.departAt!)) < 60)

let routeURL = try XCTUnwrap(URL(string: "https://api.mapbox.com/directions/v5/mapbox/driving-traffic/-121.913565,37.331832;-121.916282,37.328707.json"))
XCTAssertNotNil(RouteOptions(url: routeURL))
XCTAssertNil(MatchOptions(url: routeURL))
}

// MARK: API name-handling tests
Expand Down