-
Notifications
You must be signed in to change notification settings - Fork 6
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
Configurable headers+QoL #4
base: master
Are you sure you want to change the base?
Changes from all commits
8273610
8e6687f
2ad4b5a
fb815d5
ba01705
0b506db
10c928d
945cc92
60573b6
ccaa61d
ade7e23
c633f2c
afadb2a
00191b3
3840101
0bf145e
29ceb79
cc61967
3464454
d80094b
f25934f
b80b7a8
bd28963
6afd972
620158e
1e61953
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,38 +2,31 @@ | |
# Be sure to run `pod lib lint DDMockiOS.podspec' to ensure this is a | ||
# valid spec before submitting. | ||
# | ||
# Any lines starting with a # are optional, but their use is encouraged | ||
# To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html | ||
# | ||
|
||
Pod::Spec.new do |spec| | ||
spec.name = 'DDMockiOS' | ||
spec.version = '0.1.5' | ||
spec.version = '2.0' | ||
spec.summary = 'Deloitte Digital simple network mocking library for iOS' | ||
|
||
# This description is used to generate tags and improve search results. | ||
# * Think: What does it do? Why did you write it? What is the focus? | ||
# * Try to keep it short, snappy and to the point. | ||
# * Write the description between the DESC delimiters below. | ||
# * Finally, don't worry about the indent, CocoaPods strips it! | ||
|
||
# spec.description = <<-DESC | ||
# Deloitte Digital simple network mocking library for iOS | ||
# DESC | ||
spec.description = 'Deloitte Digital simple network mocking library for iOS. Runtime configurable mocking library with highly flexible usage. Integrated tooling for delivery and testing teams.' | ||
|
||
spec.homepage = 'https://github.com/DeloitteDigitalAPAC/ddmock-ios' | ||
# spec.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2' | ||
spec.license = { :type => 'MIT', :file => 'LICENSE' } | ||
spec.author = 'Deloitte Digital Asia Pacific' | ||
spec.source = { :git => "https://github.com/DeloitteDigitalAPAC/ddmock-ios.git", :tag => 'v' + spec.version.to_s } | ||
spec.source = { :git => "https://github.com/will-rigney/ddmock-ios.git", :tag => 'v' + spec.version.to_s } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs to be set back to the right url |
||
|
||
spec.ios.deployment_target = '11.0' | ||
|
||
spec.source_files = 'DDMockiOS' | ||
spec.source_files = 'Sources' | ||
|
||
spec.preserve_paths = [ | ||
'init-mocks.py', | ||
] | ||
'Generate/ddmock.py', | ||
'Resources/general.json', | ||
'Resources/root.json', | ||
'Resources/endpoint.json', | ||
] | ||
|
||
spec.swift_version = '5' | ||
spec.static_framework = true | ||
|
Large diffs are not rendered by default.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,306 @@ | ||
import os | ||
import plistlib | ||
import pathlib | ||
import logging | ||
import json | ||
import argparse | ||
import copy | ||
|
||
|
||
# create the map of endpoints from mockfiles | ||
def generate_map(mockfiles_path): | ||
# init an empty map object | ||
endpoint_map = {} | ||
|
||
# init header object | ||
# this is actually keyed including the individual file, not just the endpoint | ||
# this is a map between string keys and idk what | ||
header_map = {} | ||
|
||
# todo: may be a way to do this in two traversals using glob | ||
|
||
# walks all the mockfiles and for each creates just the leading path? | ||
|
||
# recursive directory traversal | ||
# todo: define subdir - it is the subdirectory configured (?) | ||
for current, dirs, files in os.walk(mockfiles_path): | ||
|
||
# iterate through mockfiles | ||
for file in files: | ||
# todo: open this up to all filetypes | ||
# should potentially change the application type | ||
# or do this with headers? | ||
# only the json files | ||
if not file.endswith(".json"): | ||
continue | ||
|
||
# process header files separately | ||
if file.endswith(".h.json"): | ||
with open(current + '/' + file, "r+") as headers: | ||
res = json.load(headers) | ||
# todo: duplicate | ||
key = current.replace(mockfiles_path, "") | ||
|
||
# strip the leading slash if present | ||
if key.startswith("/"): | ||
key = key.replace("/", "", 1) | ||
|
||
# add the trailing file path for header keys | ||
key = f"{get_canonical_key(key)}.{file}" | ||
|
||
# not working in xcode | ||
# key.removesuffix(".h.json") | ||
if key.endswith('.h.json'): | ||
key = key[:-7] # ugly magic | ||
|
||
header_map[key] = res | ||
continue | ||
|
||
# currently this only needs to look at files | ||
# and create the key from the path | ||
# should be a simpler api to use | ||
|
||
# todo: think there is a more normal way to check for only json files | ||
|
||
# this is to get the key from the json object path | ||
key = current.replace(mockfiles_path, "") | ||
|
||
# strip the leading slash if present | ||
if key.startswith("/"): | ||
key = key.replace("/", "", 1) | ||
|
||
# map is accessed here (therefore make this a function return point) | ||
# this logic is duplicated in the swift code | ||
# this does the same thing as swift code to run it | ||
# "get or insert" | ||
if key in endpoint_map: | ||
files = endpoint_map[key] | ||
files.append(file) | ||
else: | ||
endpoint_map[key] = [file] | ||
|
||
return (endpoint_map, header_map) | ||
|
||
|
||
# def generate_header_map(): | ||
|
||
# open a file with the name res and return it | ||
|
||
|
||
def load_json_resource(res): | ||
logging.info(f"loading json resource: {res}") | ||
with open(res, "r") as file: | ||
res = json.load(file) | ||
return res | ||
|
||
|
||
# pure function returns an object to add to root preference specifier | ||
def create_root_item(filename): | ||
new_item = {} | ||
new_item['Type'] = 'PSChildPaneSpecifier' | ||
new_item['File'] = filename | ||
new_item['Title'] = filename | ||
return new_item | ||
|
||
|
||
# create an item to add to endpoint plist for the group header for headers | ||
def create_headers_group_item(title): | ||
new_item = {} | ||
new_item['Type'] = 'PSGroupSpecifier' | ||
new_item['Title'] = title | ||
return new_item | ||
|
||
|
||
# create a new item to represent a header from the key, title & value | ||
def create_headers_item(key, title, value): | ||
new_item = {} | ||
new_item['Type'] = "PSTextFieldSpecifier" # PSTitleValueSpecifier" | ||
new_item['DefaultValue'] = value | ||
new_item['Title'] = title | ||
new_item["Key"] = key | ||
return new_item | ||
|
||
|
||
# get resource path from canonical path of script | ||
def get_ddmock_path(resources_path): | ||
path = os.path.dirname(os.path.realpath(__file__)) | ||
path = pathlib.Path(path) | ||
path = path.parent.joinpath(resources_path).absolute() | ||
return path | ||
|
||
|
||
# transforms some input (most likely a path) into a canonical key | ||
# relies on the input being unique to be "canonical" | ||
def get_canonical_key(path): | ||
key = path.replace("/", ".") | ||
return key | ||
|
||
|
||
# creates a copy of endpoint & replaces keys | ||
# for endpointh path and endpoint key (filename) | ||
def create_endpoint_plist(endpoint, endpoint_path, filename, files): | ||
|
||
# copy a new endpoint object | ||
new_endpoint = copy.deepcopy(endpoint) | ||
|
||
logging.info(f"new endpoint: {new_endpoint}") | ||
|
||
# replace variable keys in all the endpoint items | ||
|
||
# for each item in preference specifiers list | ||
for index, item in enumerate(new_endpoint["PreferenceSpecifiers"]): | ||
# construct a new item | ||
new_item = {} | ||
# for every key value par in the item dict | ||
for key, value in item.items(): | ||
try: | ||
new_value = value.replace( | ||
"$endpointPathName", f"{endpoint_path}") | ||
new_value = new_value.replace("$endpointPathKey", filename) | ||
new_item[key] = new_value | ||
except AttributeError: | ||
# value can be any type, may not be string | ||
new_item[key] = value | ||
|
||
new_endpoint["PreferenceSpecifiers"][index] = new_item | ||
|
||
# set the mockfile "values" and "titles" fields | ||
for setting in filter(lambda item: item['Title'] == "Mock file", new_endpoint['PreferenceSpecifiers']): | ||
setting["Values"] = list(range(0, len(files))) | ||
setting["Titles"] = files | ||
|
||
return new_endpoint | ||
|
||
|
||
def main(mockfiles_path, output_path): | ||
print("Running *.plist generation...") | ||
print(f"Path to mockfiles: {mockfiles_path}") | ||
print(f"Output path: {output_path}") | ||
|
||
path = get_ddmock_path("Resources") | ||
# print(f"Template path: {path}") | ||
|
||
# first create the map | ||
# this is where the directory traversal happens | ||
print("Creating map of endpoint paths and mock files...") | ||
(endpoint_map, header_map) = generate_map(mockfiles_path) | ||
|
||
# todo: lazy evaluation in logging | ||
logging.info(f" map: {endpoint_map}") | ||
|
||
# start creating settings bundle | ||
print("Creating Settings.bundle...") | ||
|
||
# Settings.bundle is really just a directory | ||
# first create directory if it doesn't exist | ||
if not os.path.exists(output_path): | ||
os.makedirs(output_path) | ||
|
||
# load templates | ||
print("Loading JSON templates...") | ||
root = load_json_resource(path.joinpath("root.json")) | ||
endpoint = load_json_resource(path.joinpath("endpoint.json")) | ||
|
||
# save each endpoint as a plist | ||
for endpoint_path, files in endpoint_map.items(): | ||
|
||
print(f"Adding endpoint: {endpoint_path}") | ||
|
||
# replaces the slashes with periods for ... | ||
canonical_key = get_canonical_key(endpoint_path) | ||
|
||
# add endpoint to root plist | ||
new_item = create_root_item(canonical_key) | ||
root['PreferenceSpecifiers'].append(new_item) | ||
|
||
# create new endpoint object from endpoint template | ||
new_endpoint = create_endpoint_plist( | ||
endpoint, endpoint_path, canonical_key, files) | ||
|
||
# header generation | ||
|
||
# todo: this is currently not very good, selecting different mockfiles should change headers | ||
|
||
# check if there are any headers and add them if there are | ||
for file in files: | ||
try: | ||
# try and get some headers | ||
# this is the header key e.g. todos.get.010_title | ||
key = get_canonical_key(f"{endpoint_path}.{file[:-5]}") | ||
headers = header_map[key] | ||
|
||
except KeyError: | ||
print(f"no headers for {endpoint_path}.{file}, key: {key}") | ||
continue | ||
|
||
# use python dicts to build ios plists more easily | ||
|
||
# todo: list comprehension is more pythonic | ||
for (index, (title, value)) in enumerate(headers.items()): | ||
|
||
# if we survived this far, add the group settings heading | ||
# group specifiers are needed for the correct ordering | ||
group = create_headers_group_item(title) | ||
new_endpoint['PreferenceSpecifiers'].append(group) | ||
|
||
# separate items for title and value | ||
# keys for headers is endpoint path + header index | ||
# this is the oother header key | ||
key = get_canonical_key(f"{endpoint_path}.{file[:-5]}") | ||
key = f"{key}{index}_title" | ||
# create a new item for the header | ||
group = create_headers_item(key, "Title", title) | ||
# add the item to the list of preference specifiers | ||
new_endpoint['PreferenceSpecifiers'].append(group) | ||
|
||
key = get_canonical_key(f"{endpoint_path}.{file[:-5]}") | ||
key = f"{key}{index}_value" | ||
# create a new item for the header | ||
group = create_headers_item(key, "Value", value) | ||
# add the item to the list of preference specifiers | ||
new_endpoint['PreferenceSpecifiers'].append(group) | ||
|
||
print(f"added headers: {headers}") | ||
|
||
# dump the endpoint to plist | ||
with open(output_path + canonical_key + ".plist", "wb") as fout: | ||
plistlib.dump(new_endpoint, fout, fmt=plistlib.FMT_XML) | ||
|
||
# create general plist from json template | ||
print("Load general.plist template...") | ||
general = path.joinpath("general.json") | ||
general = load_json_resource(general) | ||
|
||
# write general plist | ||
print("Writing general.plist...") | ||
with open(os.path.join(output_path, "general.plist"), "wb") as output: | ||
plistlib.dump(general, output, fmt=plistlib.FMT_XML) | ||
|
||
# write root plist | ||
print("Writing Root.plist...") | ||
with open(output_path + "Root.plist", "wb") as output: | ||
plistlib.dump(root, output, fmt=plistlib.FMT_XML) | ||
|
||
# finished | ||
print("Done!") | ||
|
||
|
||
if __name__ == "__main__": | ||
|
||
# create argument parser | ||
parser = argparse.ArgumentParser( | ||
description='Generate Settings.bundle for DDMockiOS') | ||
|
||
# 1st argument is mockfiles directory | ||
parser.add_argument('mockfiles_path', nargs='?', | ||
default="Resources/mockfiles") | ||
|
||
# 2nd argument is output path | ||
parser.add_argument('output_path', nargs='?', | ||
default="Settings.bundle/") | ||
|
||
# parse arguments | ||
args = parser.parse_args() | ||
|
||
# start execution | ||
main(args.mockfiles_path, args.output_path) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import plistlib | ||
import json | ||
|
||
def create_general_plist(): | ||
print("creating plist in future") | ||
|
||
|
||
def plist_to_json(path): | ||
|
||
with open(path, "rb") as general: | ||
# string = general.read() | ||
# print(string) | ||
plist = plistlib.load(general, fmt=plistlib.FMT_XML) | ||
print(plist) | ||
with open("general.json", "w") as output: | ||
json.dump(plist, output, indent=4) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# convert swagger api spec into ddmock plist | ||
|
||
def swagger_to_plist(): | ||
print("swag") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
{ | ||
"PreferenceSpecifiers": [ | ||
{ | ||
"Type": "PSToggleSwitchSpecifier", | ||
"Title": "Use real API", | ||
"Key": "$endpointPathKey_use_real_api", | ||
"DefaultValue": false | ||
}, | ||
{ | ||
"DefaultValue": "$endpointPathName", | ||
"Type": "PSTitleValueSpecifier", | ||
"Title": "Endpoint", | ||
"Key": "$endpointPathKey_endpoint" | ||
}, | ||
{ | ||
"Type": "PSTextFieldSpecifier", | ||
"DefaultValue": "400", | ||
"Title": "Response Time (ms)", | ||
"Key": "$endpointPathKey_response_time" | ||
}, | ||
{ | ||
"Type": "PSTextFieldSpecifier", | ||
"DefaultValue": "200", | ||
"Title": "Status Code", | ||
"Key": "$endpointPathKey_status_code" | ||
}, | ||
{ | ||
"Type": "PSMultiValueSpecifier", | ||
"Title": "Mock file", | ||
"Key": "$endpointPathKey_mock_file", | ||
"DefaultValue": 0, | ||
"Values": [], | ||
"Titles": [] | ||
} | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
{ | ||
"PreferenceSpecifiers": [ | ||
{ | ||
"Type": "PSToggleSwitchSpecifier", | ||
"Title": "Stubbed Token Refresh Succeeds", | ||
"Key": "stubbed_token_refresh_success", | ||
"DefaultValue": true | ||
}, | ||
{ | ||
"Type": "PSTextFieldSpecifier", | ||
"Title": "Stubbed Token Refresh Delay (ms)", | ||
"Key": "stubbed_token_refresh_delay", | ||
"DefaultValue": 100 | ||
}, | ||
{ | ||
"Type": "PSGroupSpecifier", | ||
"Title": "Launch Options" | ||
}, | ||
{ | ||
"Type": "PSToggleSwitchSpecifier", | ||
"Title": "Force Jailbroken", | ||
"Key": "launch_options.force_jailbroken", | ||
"DefaultValue": false | ||
}, | ||
{ | ||
"Type": "PSToggleSwitchSpecifier", | ||
"Title": "Force Upgrade", | ||
"Key": "launch_options.force_upgrade", | ||
"DefaultValue": false | ||
}, | ||
{ | ||
"Type": "PSToggleSwitchSpecifier", | ||
"Title": "Is Subsequent Login", | ||
"Key": "isSubsequentLogin", | ||
"DefaultValue": false | ||
}, | ||
{ | ||
"Type": "PSMultiValueSpecifier", | ||
"Title": "is_sqa_enabled", | ||
"Key": "isSQAEnabled_debug", | ||
"DefaultValue": "TRUE", | ||
"Values": [ | ||
"TRUE", | ||
"FALSE", | ||
"ONE", | ||
"TWO" | ||
], | ||
"Titles": [ | ||
"TRUE (already set)", | ||
"FALSE (must be set)", | ||
"ONE (can skip once)", | ||
"TWO (can skip twice)" | ||
] | ||
} | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
<?xml version="1.0" encoding="UTF-8"?> | ||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
<plist version="1.0"> | ||
<dict> | ||
<key>PreferenceSpecifiers</key> | ||
<array> | ||
<dict> | ||
<key>Type</key> | ||
<string>PSToggleSwitchSpecifier</string> | ||
<key>Title</key> | ||
<string>Stubbed Token Refresh Succeeds</string> | ||
<key>Key</key> | ||
<string>stubbed_token_refresh_success</string> | ||
<key>DefaultValue</key> | ||
<true/> | ||
</dict> | ||
<dict> | ||
<key>Type</key> | ||
<string>PSTextFieldSpecifier</string> | ||
<key>Title</key> | ||
<string>Stubbed Token Refresh Delay (ms)</string> | ||
<key>Key</key> | ||
<string>stubbed_token_refresh_delay</string> | ||
<key>DefaultValue</key> | ||
<integer>100</integer> | ||
</dict> | ||
<dict> | ||
<key>Type</key> | ||
<string>PSGroupSpecifier</string> | ||
<key>Title</key> | ||
<string>Launch Options</string> | ||
</dict> | ||
<dict> | ||
<key>Type</key> | ||
<string>PSToggleSwitchSpecifier</string> | ||
<key>Title</key> | ||
<string>Force Jailbroken</string> | ||
<key>Key</key> | ||
<string>launch_options.force_jailbroken</string> | ||
<key>DefaultValue</key> | ||
<false/> | ||
</dict> | ||
<dict> | ||
<key>Type</key> | ||
<string>PSToggleSwitchSpecifier</string> | ||
<key>Title</key> | ||
<string>Force Upgrade</string> | ||
<key>Key</key> | ||
<string>launch_options.force_upgrade</string> | ||
<key>DefaultValue</key> | ||
<false/> | ||
</dict> | ||
<dict> | ||
<key>Type</key> | ||
<string>PSToggleSwitchSpecifier</string> | ||
<key>Title</key> | ||
<string>Is Subsequent Login</string> | ||
<key>Key</key> | ||
<string>isSubsequentLogin</string> | ||
<key>DefaultValue</key> | ||
<false/> | ||
</dict> | ||
<dict> | ||
<key>Type</key> | ||
<string>PSMultiValueSpecifier</string> | ||
<key>Title</key> | ||
<string>is_sqa_enabled</string> | ||
<key>Key</key> | ||
<string>isSQAEnabled_debug</string> | ||
<key>DefaultValue</key> | ||
<string>TRUE</string> | ||
<key>Values</key> | ||
<array> | ||
<string>TRUE</string> | ||
<string>FALSE</string> | ||
<string>ONE</string> | ||
<string>TWO</string> | ||
</array> | ||
<key>Titles</key> | ||
<array> | ||
<string>TRUE (already set)</string> | ||
<string>FALSE (must be set)</string> | ||
<string>ONE (can skip once)</string> | ||
<string>TWO (can skip twice)</string> | ||
</array> | ||
</dict> | ||
</array> | ||
</dict> | ||
</plist> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"example-header-1": "some value", | ||
"example-header-2": false | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"message": "good job" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
{ | ||
"PreferenceSpecifiers": [ | ||
{ | ||
"DefaultValue": true, | ||
"Key": "use_real_apis", | ||
"Title": "Use real APIs", | ||
"Type": "PSToggleSwitchSpecifier" | ||
}, | ||
{ | ||
"File": "general", | ||
"Title": "General", | ||
"Type": "PSChildPaneSpecifier" | ||
}, | ||
{ | ||
"Title": "MOCK", | ||
"Type": "PSGroupSpecifier", | ||
"FooterText": "Note that strict mode being set at build time will override the 'Use real APIs' setting." | ||
} | ||
], | ||
"StringsTable": "Root" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
enum Constants { | ||
// todo: make this more obvious or configurable | ||
/// path under resources directory | ||
static let mockDirectory = "/mockfiles" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
|
||
// todo: extension on string idk | ||
extension String { | ||
|
||
/** | ||
gets matches & checks they are not equal to nil | ||
*/ | ||
func matches(_ regex: String) -> Bool { | ||
let matches = range( | ||
of: regex, | ||
options: .regularExpression, | ||
range: nil, | ||
locale: nil) | ||
return matches != nil | ||
} | ||
|
||
/** | ||
Replace regex matches | ||
replaceWith is the template (withTemplate) | ||
*/ | ||
func replacingRegexMatches( | ||
pattern: String, | ||
replaceWith: String = "") -> String { | ||
|
||
var newString = "" | ||
do { | ||
let regex = try NSRegularExpression( | ||
pattern: pattern, | ||
options: NSRegularExpression.Options.caseInsensitive) | ||
let range = NSMakeRange(0, count) | ||
newString = regex.stringByReplacingMatches( | ||
in: self, | ||
options: [], | ||
range: range, | ||
withTemplate: replaceWith) | ||
} | ||
catch { | ||
debugPrint("Error \(error)") | ||
} | ||
return newString | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
import Foundation | ||
|
||
//struct MockResponse { | ||
// let headers: [String: String] | ||
// // other elements a response might have | ||
// fileprivate init( | ||
// headers: [String: String]) { | ||
// | ||
// self.headers = headers | ||
// } | ||
//} | ||
// | ||
//fileprivate class MockResponseBuilder { | ||
// private var headers: [String: String] = [:] | ||
// | ||
// func addHeaders(contentLength: Int?) { | ||
// self.headers = ResponseHelper.getMockHeaders(contentLength: contentLength) | ||
// } | ||
// | ||
// func build() -> MockResponse { | ||
// return MockResponse(headers: headers) | ||
// } | ||
//} | ||
/* | ||
basically we want to have some response type, and we want to both create it | ||
and send it | ||
|
||
*/ | ||
|
||
// todo: maybe think about replacing this with a builder | ||
// todo: move this out of amorphous 'helper' | ||
class ResponseHelper { | ||
|
||
// todo: allow headers to be configurable | ||
static func getMockHeaders(contentLength: Int?) -> [String: String] { | ||
var headers: [String: String] = [:] | ||
// content type | ||
// todo: get these from somewhere | ||
headers["Content-Type"] = "application/json" | ||
if let contentLength = contentLength { | ||
headers["Content-Length"] = "\(contentLength)" | ||
} | ||
return headers | ||
} | ||
|
||
// this maybe doesn't belong here | ||
static func getKeyFromPath(path: String, method: String) -> String { | ||
let matches = path.replacingRegexMatches( | ||
pattern: "^/", | ||
replaceWith: "") | ||
// method string is always lowercased | ||
return "\(matches)/\(method.lowercased())/" | ||
} | ||
|
||
static func createMockResponse( | ||
url: URL, | ||
statusCode: Int, | ||
headers: [String: String]) -> HTTPURLResponse? { | ||
|
||
return HTTPURLResponse( | ||
url: url, | ||
statusCode: statusCode, | ||
httpVersion: "HTTP/1.1", | ||
headerFields: headers) | ||
} | ||
|
||
// todo: this response should be configurable somehow like the header | ||
// todo: hide this | ||
// should know what the path is from the entry | ||
static func getData(_ entry: MockEntry) -> Data? { | ||
|
||
let file = entry.getSelectedFile() | ||
|
||
// todo: file should just be a string of the directory (?) | ||
let path = Bundle.main.resourcePath! + Constants.mockDirectory | ||
|
||
let url = URL(fileURLWithPath: "\(path)/\(file)") | ||
|
||
return try? Data( | ||
contentsOf: url, | ||
options: .mappedIfSafe) | ||
} | ||
|
||
/// | ||
static func sendMockResponse( | ||
urlProtocol: URLProtocol, | ||
client: URLProtocolClient, | ||
response: HTTPURLResponse, | ||
data: Data?) { | ||
|
||
// send response | ||
client.urlProtocol( | ||
urlProtocol, | ||
didReceive: response, | ||
cacheStoragePolicy: .notAllowed) | ||
|
||
// send response data if available | ||
if let data = data { | ||
client.urlProtocol( | ||
urlProtocol, | ||
didLoad: data) | ||
} | ||
|
||
// finish loading | ||
client.urlProtocolDidFinishLoading(urlProtocol) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import Foundation | ||
|
||
class UserDefaultsHelper { | ||
enum SettingsKey: String { | ||
case statusCode = "_status_code" | ||
case responseTime = "_response_time" | ||
case endpoint = "_endpoint" | ||
case mockFile = "_mock_file" | ||
case useRealApi = "_use_real_api" | ||
case headerValue = "_value" | ||
case headerTitle = "_title" | ||
case globalUseRealApis = "use_real_apis" | ||
} | ||
|
||
/** | ||
Helper function, gets an item from the settings bundle | ||
by replacing '/'s with '.' in the key, then adding the item ray value | ||
Returns userdefaults item for this key. | ||
*/ | ||
static func getInteger(key: String, item: SettingsKey) -> Int { | ||
let key = getSettingsBundleKey(key: key) + item.rawValue | ||
return UserDefaults.standard.integer(forKey: key) | ||
} | ||
|
||
static func getObject<T>(key: String, item: SettingsKey) -> T? { | ||
let key = getSettingsBundleKey(key: key) + item.rawValue | ||
return UserDefaults.standard.object(forKey: key) as? T | ||
} | ||
|
||
static func getString(key: String, item: SettingsKey) -> String? { | ||
let key = getSettingsBundleKey(key: key) + item.rawValue | ||
return UserDefaults.standard.string(forKey: key) | ||
} | ||
|
||
static func getTitleValuePair(key: String) -> (title: String?, value: String?)? { | ||
let title = getString(key: key, item: .headerTitle) | ||
let value = getString(key: key, item: .headerValue) | ||
|
||
if title == nil && value == nil { return nil } | ||
|
||
return (title, value) | ||
} | ||
} | ||
|
||
// replaces / with . to be consistent with other keys | ||
private func getSettingsBundleKey(key: String) -> String { | ||
return key.replacingOccurrences(of: "/", with: ".") | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import Foundation | ||
|
||
/** | ||
mock entry struct | ||
*/ | ||
struct MockEntry: Codable { | ||
// constants | ||
private static let defaultResponseTime = 400 | ||
private static let defaultStatusCode = 200 | ||
|
||
// ok | ||
let path: String | ||
|
||
// todo: less mutability | ||
var files: [String] = [] | ||
|
||
// todo: more thread safety | ||
var selectedFile = 0 | ||
|
||
// | ||
private var statusCode = defaultStatusCode | ||
|
||
// | ||
var responseTime = defaultResponseTime | ||
|
||
/// | ||
init(path: String, files: [String]) { | ||
self.path = path | ||
self.files = files | ||
} | ||
|
||
// this is the key for the selected file in the files list for an entry | ||
func getSelectedFile() -> String { | ||
let index = UserDefaultsHelper.getInteger(key: path, item: .mockFile) | ||
return files[index] | ||
} | ||
|
||
// get status code for an entry | ||
func getStatusCode() -> Int { | ||
return UserDefaultsHelper.getObject( | ||
key: path, | ||
item: .statusCode) ?? MockEntry.defaultStatusCode | ||
} | ||
|
||
// get use real api | ||
func useRealAPI() -> Bool { | ||
return Self.getGlobalUseRealAPIs() | ||
|| getEndpointUseRealAPI(key: path) | ||
} | ||
|
||
// get response time | ||
func getResponseTime() -> Int { | ||
return UserDefaultsHelper.getObject( | ||
key: path, | ||
item: .responseTime) ?? MockEntry.defaultResponseTime | ||
} | ||
|
||
func getHeaders() -> [String: String]? { | ||
// get a group or an array? | ||
// ok this one is funny | ||
var headers: [String: String] = [:] | ||
func getHeaderKey(_ i: Int) -> String { | ||
let selectedFile = getSelectedFile() | ||
let trimIndex = selectedFile.lastIndex(of: ".") | ||
// probably a more readable way | ||
let filename = trimIndex != nil | ||
? String(selectedFile.prefix(upTo: trimIndex!)) | ||
: selectedFile | ||
return "\(filename)\(i)" | ||
} | ||
|
||
var i = 0 | ||
while | ||
let (title, value) = UserDefaultsHelper.getTitleValuePair(key: getHeaderKey(i)) { | ||
headers[title ?? ""] = value ?? "" | ||
i += 1 | ||
} | ||
return headers | ||
} | ||
|
||
private func getEndpointUseRealAPI(key: String) -> Bool { | ||
return UserDefaultsHelper.getObject(key: key, item: .useRealApi) ?? false | ||
} | ||
|
||
// read global from user defaults | ||
static func getGlobalUseRealAPIs() -> Bool { | ||
return UserDefaultsHelper.getObject(key: "", item: .globalUseRealApis) ?? false | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import Foundation | ||
|
||
/** | ||
Internal storage wrapper for mock entries. | ||
To reinitialise a list of mocks, just create a new MockRepository | ||
and drop the old one. | ||
*/ | ||
final class MockRepository { | ||
|
||
/// map storage of mock entries | ||
private let storage: MockStorage | ||
|
||
/** | ||
iterate through files & populate the mocks | ||
*/ | ||
init(path: String, fm: FileManager) { | ||
var entries: [String: MockEntry] = [:] | ||
|
||
// load mock files | ||
fm | ||
.enumerator(atPath: path)? | ||
.forEach { | ||
|
||
guard | ||
let path = $0 as? String, | ||
let url = URL(string: path) else { | ||
|
||
return | ||
} | ||
guard | ||
// todo: new file schema? | ||
url.pathExtension == "json" else { | ||
|
||
return | ||
} | ||
|
||
// get the key | ||
let key = url.deletingLastPathComponent().absoluteString | ||
|
||
// put into the dictionary | ||
if var entry = entries[key] { | ||
// add the mock to the existing file list for this entry | ||
entry.files.append(url.path) | ||
} | ||
else { | ||
// create a new entry | ||
entries[key] = MockEntry(path: key, files: [url.path]) | ||
} | ||
} | ||
|
||
self.storage = MockStorage(entries: entries) | ||
} | ||
|
||
/// todo: doc | ||
func hasEntry(path: String, method: String) -> Bool { | ||
// get the key | ||
let key = ResponseHelper.getKeyFromPath(path: path, method: method) | ||
// return an entry for either a non-wildcard or wildcard path | ||
// todo: slightly confusing names | ||
guard | ||
let entry = storage.getEntry(path: key) else { | ||
return false | ||
} | ||
// entry can override this value itself | ||
return !entry.useRealAPI() | ||
} | ||
|
||
/** | ||
get the mock entry, respecting strict mode | ||
*/ | ||
func getEntry( | ||
path: String, | ||
method: String, | ||
strict: Bool, | ||
onMissing: (_ path: String?) -> Void) -> MockEntry? { | ||
|
||
// get the entry | ||
let key = ResponseHelper.getKeyFromPath(path: path, method: method) | ||
|
||
// return an entry for either a non-wildcard or wildcard path | ||
let entry = storage.getEntry(path: key) | ||
|
||
// If strict mode is enabled, a missing entry is an error. Call handler. | ||
// this will still fall through and return nil | ||
if strict && entry == nil { | ||
onMissing(path) | ||
} | ||
|
||
return entry | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
/** | ||
Class to wrap storage for mocks with nice get methods | ||
*/ | ||
final class MockStorage { | ||
private let entries: [String: MockEntry] | ||
|
||
init(entries: [String: MockEntry]) { | ||
self.entries = entries | ||
} | ||
|
||
/** | ||
get either the value for the key if it exists, | ||
or a regex entry if there is a match, | ||
or nil | ||
*/ | ||
func getEntry(path: String) -> MockEntry? { | ||
return entries[path] ?? getRegexEntry(path: path) | ||
} | ||
|
||
/** | ||
get a possible regex entry map | ||
iterates through the keys, for every key with a '_' | ||
turns it into a regex and matches against path | ||
panics on > 1 match | ||
*/ | ||
private func getRegexEntry(path: String) -> MockEntry? { | ||
// empty array | ||
var matches: [MockEntry] = [] | ||
|
||
// iterate through mock entry keys (what are these?) | ||
// these are the path without the filename, including method | ||
// whatever we're looking for should be in the value | ||
// to keep this lookup O(1) | ||
for key in entries.keys { | ||
|
||
// if key contains _ | ||
// this is to test if there is a wildcard to replace | ||
// todo: mock entry should have its own regex | ||
// shouldn't have to recompile for every request | ||
if (key.contains("_")) { | ||
|
||
// replace matches of the wildcard with ... | ||
// this matches _[^/]*_ in the path | ||
// and replaces it with the string literal "_[^/]*_ | ||
// this lets us use it as the string matcher later | ||
let regex = key.replacingRegexMatches( | ||
pattern: "_[^/]*_", | ||
replaceWith: "[^/]*") | ||
|
||
// try and match the path with the new regex string | ||
if path.matches(regex) { | ||
|
||
if let match = entries[key] { | ||
matches.append(match) | ||
} | ||
} | ||
} | ||
} | ||
// maximum of 1 match or panic | ||
guard matches.count <= 1 else { | ||
fatalError("Fatal Error: Multiple matches for regex entry.") | ||
} | ||
// return first or none | ||
return matches.first | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import Foundation | ||
|
||
/** | ||
Implementation of NSURLProtocol used to intercept requests. | ||
Needs to be inserted into the list protocal classes ... | ||
|
||
*/ | ||
public class DDMockURLProtocolClass: URLProtocol { | ||
|
||
/** | ||
convenience function to insert | ||
todo: more detail and change this interface somehow, check what others do | ||
*/ | ||
public static func insertProtocolClass( | ||
_ protocolClasses: [AnyClass])-> [AnyClass] { | ||
|
||
var protocolClasses = protocolClasses | ||
protocolClasses.insert( | ||
DDMockURLProtocolClass.self, | ||
at: 0) | ||
return protocolClasses | ||
} | ||
|
||
// todo: is this called for every request? is the mock retreived 2ce? | ||
// yes it is | ||
public override class func canInit(with task: URLSessionTask) -> Bool { | ||
|
||
if DDMock.shared.strict { return true } | ||
|
||
guard | ||
let req = task.currentRequest, | ||
let path = req.url?.path, | ||
let method = req.httpMethod else { | ||
|
||
return false | ||
} | ||
|
||
// this canInit is the only place that calls hasMockEntry | ||
// this actually retreives the mock as part of its execution | ||
// todo: caching | ||
return DDMock.shared.hasEntry(path: path, method: method) | ||
} | ||
|
||
/** | ||
The canonical version of a request is used to lookup objects in the URL cache. | ||
This process performs equality checks between URLRequest instances. | ||
|
||
This is an abstract class by default. | ||
*/ | ||
public override class func canonicalRequest(for request: URLRequest) -> URLRequest { | ||
return request | ||
} | ||
|
||
// todo: move logic to correct lifecycle point | ||
/** | ||
this is where everything happens | ||
*/ | ||
public override func startLoading() { | ||
|
||
// fetch item | ||
guard | ||
let path = request.url?.path, | ||
let method = request.httpMethod, | ||
let url = request.url else { | ||
|
||
return | ||
} | ||
|
||
// note: remove singleton could just mean restrict its usage to | ||
// within the public interface boundary or make it more explicit | ||
|
||
// todo: remove singleton | ||
guard let entry = DDMock.shared.getEntry( | ||
path: path, | ||
method: method) else { | ||
|
||
return | ||
} | ||
|
||
// get response data | ||
// todo: check in what case could this be nil | ||
let data: Data? = ResponseHelper.getData(entry) | ||
|
||
// header dictionary | ||
var headers = ResponseHelper.getMockHeaders(contentLength: data?.count) | ||
|
||
// if the entry has headers merge those too | ||
if let entryHeaders = entry.getHeaders() { | ||
headers.merge( | ||
entryHeaders, | ||
uniquingKeysWith: {(_, newValue) in newValue}) | ||
} | ||
|
||
// get status code | ||
let statusCode = entry.getStatusCode() | ||
|
||
// create response | ||
guard let response = ResponseHelper.createMockResponse( | ||
url: url, | ||
statusCode: statusCode, | ||
headers: headers) else { | ||
|
||
return | ||
} | ||
|
||
// simulate response time | ||
let time = TimeInterval(Float(entry.getResponseTime()) / 1000.0) | ||
|
||
// just use regular timer to async return the response | ||
// todo: this isn't working correctly | ||
Timer.scheduledTimer( | ||
withTimeInterval: time, | ||
repeats: false, | ||
block: | ||
{ timer in | ||
// finally send the mock response to the client | ||
ResponseHelper.sendMockResponse( | ||
urlProtocol: self, | ||
client: self.client!, | ||
response: response, | ||
data: data) | ||
}) | ||
} | ||
|
||
/// Required override of abstract prototype, does nothing. | ||
public override func stopLoading() { | ||
// nothing actually loading | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import Foundation | ||
|
||
/** | ||
This is the main DDMock entry point. | ||
|
||
*/ | ||
public final class DDMock { | ||
|
||
/// enforces mocks only and no API fall-through | ||
internal var strict: Bool = false | ||
|
||
// todo: this should be thread safe | ||
// and have a max size | ||
/// chronological order of paths | ||
private(set) var matchedPaths: [String] = [] | ||
|
||
/// needed for singleton | ||
private init() {} | ||
|
||
/** | ||
Assignable handler invoked when a mock is not present in strict mode. | ||
By default this is a panic, strict mode users may want | ||
to configure something more graceful. | ||
*/ | ||
public var onMissingMock: (_ path: String?) -> Void = { path in | ||
fatalError("missing stub for path: \(path ?? "<unknown>")") | ||
} | ||
|
||
// todo: remove the singleton if possible, require a single instance | ||
/// singleton instance of DDMock | ||
public static let shared = DDMock() | ||
|
||
/// repository for storing mocks | ||
private var repository: MockRepository! | ||
|
||
/** | ||
Initialise DDMock library | ||
This must be called on the DDMock.shared singleton | ||
by the client before DDMock can be used. | ||
*/ | ||
public func initialise(strict: Bool = false) { | ||
// todo: more consistent configuration | ||
self.strict = strict | ||
|
||
// todo: resource path | ||
let path = Bundle.main.resourcePath! + Constants.mockDirectory | ||
|
||
// load the files in the mock directory | ||
repository = MockRepository(path: path, fm: FileManager.default) | ||
} | ||
|
||
/** | ||
reset the history | ||
*/ | ||
public func clearHistory() { | ||
matchedPaths.removeAll() | ||
} | ||
|
||
/** | ||
Check if an entry exists for a given path | ||
*/ | ||
func hasEntry(path: String, method: String) -> Bool { | ||
return repository.hasEntry(path: path, method: method) | ||
} | ||
|
||
/** | ||
Return the entry for a given path, if one exists | ||
*/ | ||
func getEntry(path: String, method: String) -> MockEntry? { | ||
// get the entry | ||
guard | ||
let entry = repository.getEntry( | ||
path: path, | ||
method: method, | ||
strict: strict, | ||
onMissing: onMissingMock) else { | ||
|
||
return nil | ||
} | ||
|
||
// add to history | ||
matchedPaths.append(path) | ||
|
||
// return the entry | ||
return entry | ||
} | ||
} |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
0.1 to 2.0 is a pretty big jump