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

Configurable headers+QoL #4

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
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
25 changes: 9 additions & 16 deletions DDMockiOS.podspec
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'
Copy link
Contributor

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

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 }
Copy link
Contributor

Choose a reason for hiding this comment

The 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
166 changes: 139 additions & 27 deletions DDMockiOS.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

152 changes: 0 additions & 152 deletions DDMockiOS/DDMock.swift

This file was deleted.

67 changes: 0 additions & 67 deletions DDMockiOS/DDMockProtocol.swift

This file was deleted.

45 changes: 0 additions & 45 deletions DDMockiOS/DDMockSettingsBundleHelper.swift

This file was deleted.

34 changes: 0 additions & 34 deletions DDMockiOS/MockEntry.swift

This file was deleted.

306 changes: 306 additions & 0 deletions Generate/ddmock.py
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)
16 changes: 16 additions & 0 deletions Generate/plist.py
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)
4 changes: 4 additions & 0 deletions Generate/swagger_to_plist.py
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")
File renamed without changes.
36 changes: 36 additions & 0 deletions Resources/endpoint.json
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": []
}
]
}
56 changes: 56 additions & 0 deletions Resources/general.json
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)"
]
}
]
}
89 changes: 89 additions & 0 deletions Resources/general.plist
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>
4 changes: 4 additions & 0 deletions Resources/mockfiles/example/get/body.h.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"example-header-1": "some value",
"example-header-2": false
}
3 changes: 3 additions & 0 deletions Resources/mockfiles/example/get/body.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"message": "good job"
}
21 changes: 21 additions & 0 deletions Resources/root.json
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"
}
5 changes: 5 additions & 0 deletions Sources/Constants.swift
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"
}
4 changes: 2 additions & 2 deletions DDMockiOS/DDMockiOS.h → Sources/DDMockiOS.h
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
// DDMockiOS
//
// Created by Bunduwongse, Natalie (AU - Sydney) on 18/3/19.
// todo: update license
// Copyright © 2019 Bunduwongse, Natalie (AU - Sydney). All rights reserved.
//

@@ -14,6 +15,5 @@ FOUNDATION_EXPORT double DDMockiOSVersionNumber;
//! Project version string for DDMockiOS.
FOUNDATION_EXPORT const unsigned char DDMockiOSVersionString[];

// todo: helpful - but to be removed
// In this header, you should import all the public headers of your framework using statements like #import <DDMockiOS/PublicHeader.h>


42 changes: 42 additions & 0 deletions Sources/Extension/String+Regex.swift
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
}
}
107 changes: 107 additions & 0 deletions Sources/Helper/ResponseHelper.swift
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)
}
}
48 changes: 48 additions & 0 deletions Sources/Helper/UserDefaultsHelper.swift
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: ".")
}
89 changes: 89 additions & 0 deletions Sources/Mock/MockEntry.swift
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
}
}
91 changes: 91 additions & 0 deletions Sources/Mock/MockRepository.swift
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
}
}
66 changes: 66 additions & 0 deletions Sources/Mock/MockStorage.swift
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
}
}
129 changes: 129 additions & 0 deletions Sources/Public/DDMockURLProtocolClass.swift
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
}
}
87 changes: 87 additions & 0 deletions Sources/Public/DDMockiOS.swift
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
}
}
160 changes: 0 additions & 160 deletions init-mocks.py

This file was deleted.